From 625d621f193cdcc4b4b84befa315675ffec36ab1 Mon Sep 17 00:00:00 2001 From: Ruslan Konviser Date: Mon, 7 Sep 2026 12:58:55 +0200 Subject: [PATCH] feat(integrations): complete organization-scoped Ever Async connector and builds --- .cspell.json | 3 + .deploy/api/Dockerfile | 3 + .deploy/webapp/Dockerfile | 2 + .deploy/worker/Dockerfile | 3 + .github/workflows/build.yml | 3 + .scripts/ever-async-integration.test.cjs | 373 +++++++++ .../config/webpack-package-copy-patterns.js | 1 + .../assets/seed/integrations/ever-async.svg | 13 + apps/api/src/plugins.ts | 2 + apps/gauzy/src/plugin-ui.config.ts | 2 + apps/server/src/package.json | 2 + package.json | 43 +- .../contracts/src/lib/integration.model.ts | 3 +- .../lib/integration/default-integration.ts | 9 + .../integration-ever-async-ui/README.md | 38 +- .../ever-async-connect.component.html | 333 +++++--- .../ever-async-connect.component.scss | 87 +- .../ever-async-connect.component.ts | 274 +++++-- .../src/lib/services/ever-async.service.ts | 122 ++- .../plugins/integration-ever-async/README.md | 77 +- .../integration-ever-async/package.json | 1 + .../integration-ever-async/project.json | 14 +- .../configure-ever-async-integration.dto.ts | 51 +- .../lib/dto/ever-async-user-mapping.dto.ts | 22 +- .../lib/dto/update-ever-async-settings.dto.ts | 17 +- .../dto/verify-ever-async-connection.dto.ts | 23 +- .../lib/ever-async-connector.controller.ts | 63 ++ .../src/lib/ever-async-integration.service.ts | 758 ++++++++++-------- .../src/lib/ever-async-setting.enum.ts | 34 +- .../src/lib/ever-async.controller.ts | 103 +-- .../src/lib/ever-async.module.ts | 5 +- packages/ui-core/i18n/assets/i18n/en.json | 43 + tsconfig.json | 2 + 33 files changed, 1691 insertions(+), 838 deletions(-) create mode 100644 .scripts/ever-async-integration.test.cjs create mode 100644 apps/api/src/assets/seed/integrations/ever-async.svg create mode 100644 packages/plugins/integration-ever-async/src/lib/ever-async-connector.controller.ts diff --git a/.cspell.json b/.cspell.json index d1f6397798..15b473850e 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,6 +4,9 @@ "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", "ignoreRegExpList": ["GHSA-[A-Za-z0-9-]+"], "words": [ + "healthz", + "hashtext", + "xact", "typecheck", "ELIFECYCLE", "PRIMARYKEY", diff --git a/.deploy/api/Dockerfile b/.deploy/api/Dockerfile index 0d28b32033..aefbdb27da 100644 --- a/.deploy/api/Dockerfile +++ b/.deploy/api/Dockerfile @@ -72,6 +72,7 @@ COPY --chown=node:node packages/plugins/integration-jira/package.json ./packages COPY --chown=node:node packages/plugins/integration-make-com/package.json ./packages/plugins/integration-make-com/ COPY --chown=node:node packages/plugins/integration-sim/package.json ./packages/plugins/integration-sim/ COPY --chown=node:node packages/plugins/integration-plane/package.json ./packages/plugins/integration-plane/ +COPY --chown=node:node packages/plugins/integration-ever-async/package.json ./packages/plugins/integration-ever-async/ COPY --chown=node:node packages/plugins/integration-zapier/package.json ./packages/plugins/integration-zapier/ COPY --chown=node:node packages/plugins/jitsu-analytics/package.json ./packages/plugins/jitsu-analytics/ COPY --chown=node:node packages/plugins/sentry-tracing/package.json ./packages/plugins/sentry-tracing/ @@ -185,6 +186,7 @@ COPY --chown=node:node packages/plugins/integration-jira/package.json ./packages COPY --chown=node:node packages/plugins/integration-make-com/package.json ./packages/plugins/integration-make-com/ COPY --chown=node:node packages/plugins/integration-sim/package.json ./packages/plugins/integration-sim/ COPY --chown=node:node packages/plugins/integration-plane/package.json ./packages/plugins/integration-plane/ +COPY --chown=node:node packages/plugins/integration-ever-async/package.json ./packages/plugins/integration-ever-async/ COPY --chown=node:node packages/plugins/integration-zapier/package.json ./packages/plugins/integration-zapier/ COPY --chown=node:node packages/plugins/jitsu-analytics/package.json ./packages/plugins/jitsu-analytics/ COPY --chown=node:node packages/plugins/sentry-tracing/package.json ./packages/plugins/sentry-tracing/ @@ -377,6 +379,7 @@ RUN --mount=type=bind,from=prod-dependencies,source=/srv/gauzy/node_modules,targ cp -r /mnt/packages/plugins/integration-make-com/node_modules ./node_modules/@gauzy/plugin-integration-make-com/ && \ cp -r /mnt/packages/plugins/integration-sim/node_modules ./node_modules/@gauzy/plugin-integration-sim/ && \ cp -r /mnt/packages/plugins/integration-plane/node_modules ./node_modules/@gauzy/plugin-integration-plane/ && \ + if [ -d /mnt/packages/plugins/integration-ever-async/node_modules ]; then cp -r /mnt/packages/plugins/integration-ever-async/node_modules ./node_modules/@gauzy/plugin-integration-ever-async/; fi && \ cp -r /mnt/packages/plugins/integration-upwork/node_modules ./node_modules/@gauzy/plugin-integration-upwork/ && \ cp -r /mnt/packages/plugins/integration-zapier/node_modules ./node_modules/@gauzy/plugin-integration-zapier/ && \ cp -r /mnt/packages/plugins/jitsu-analytics/node_modules ./node_modules/@gauzy/plugin-jitsu-analytics/ && \ diff --git a/.deploy/webapp/Dockerfile b/.deploy/webapp/Dockerfile index e1e3916893..19c0b4ec3c 100644 --- a/.deploy/webapp/Dockerfile +++ b/.deploy/webapp/Dockerfile @@ -74,7 +74,9 @@ COPY --chown=node:node packages/plugins/integration-zapier-ui/package.json ./pac COPY --chown=node:node packages/plugins/integration-zapier/package.json ./packages/plugins/integration-zapier/ COPY --chown=node:node packages/plugins/integration-sim/package.json ./packages/plugins/integration-sim/ COPY --chown=node:node packages/plugins/integration-plane/package.json ./packages/plugins/integration-plane/ +COPY --chown=node:node packages/plugins/integration-ever-async/package.json ./packages/plugins/integration-ever-async/ COPY --chown=node:node packages/plugins/integration-plane-ui/package.json ./packages/plugins/integration-plane-ui/ +COPY --chown=node:node packages/plugins/integration-ever-async-ui/package.json ./packages/plugins/integration-ever-async-ui/ COPY --chown=node:node packages/plugins/integration-sim-ui/package.json ./packages/plugins/integration-sim-ui/ COPY --chown=node:node packages/plugins/integration-activepieces/package.json ./packages/plugins/integration-activepieces/ COPY --chown=node:node packages/plugins/integration-activepieces-ui/package.json ./packages/plugins/integration-activepieces-ui/ diff --git a/.deploy/worker/Dockerfile b/.deploy/worker/Dockerfile index 4b150c076d..a620e4b4fc 100644 --- a/.deploy/worker/Dockerfile +++ b/.deploy/worker/Dockerfile @@ -72,6 +72,7 @@ COPY --chown=node:node packages/plugins/integration-jira/package.json ./packages COPY --chown=node:node packages/plugins/integration-make-com/package.json ./packages/plugins/integration-make-com/ COPY --chown=node:node packages/plugins/integration-sim/package.json ./packages/plugins/integration-sim/ COPY --chown=node:node packages/plugins/integration-plane/package.json ./packages/plugins/integration-plane/ +COPY --chown=node:node packages/plugins/integration-ever-async/package.json ./packages/plugins/integration-ever-async/ COPY --chown=node:node packages/plugins/integration-zapier/package.json ./packages/plugins/integration-zapier/ COPY --chown=node:node packages/plugins/jitsu-analytics/package.json ./packages/plugins/jitsu-analytics/ COPY --chown=node:node packages/plugins/sentry-tracing/package.json ./packages/plugins/sentry-tracing/ @@ -185,6 +186,7 @@ COPY --chown=node:node packages/plugins/integration-jira/package.json ./packages COPY --chown=node:node packages/plugins/integration-make-com/package.json ./packages/plugins/integration-make-com/ COPY --chown=node:node packages/plugins/integration-sim/package.json ./packages/plugins/integration-sim/ COPY --chown=node:node packages/plugins/integration-plane/package.json ./packages/plugins/integration-plane/ +COPY --chown=node:node packages/plugins/integration-ever-async/package.json ./packages/plugins/integration-ever-async/ COPY --chown=node:node packages/plugins/integration-zapier/package.json ./packages/plugins/integration-zapier/ COPY --chown=node:node packages/plugins/jitsu-analytics/package.json ./packages/plugins/jitsu-analytics/ COPY --chown=node:node packages/plugins/sentry-tracing/package.json ./packages/plugins/sentry-tracing/ @@ -377,6 +379,7 @@ RUN --mount=type=bind,from=prod-dependencies,source=/srv/gauzy/node_modules,targ cp -r /mnt/packages/plugins/integration-make-com/node_modules ./node_modules/@gauzy/plugin-integration-make-com/ && \ cp -r /mnt/packages/plugins/integration-sim/node_modules ./node_modules/@gauzy/plugin-integration-sim/ && \ cp -r /mnt/packages/plugins/integration-plane/node_modules ./node_modules/@gauzy/plugin-integration-plane/ && \ + if [ -d /mnt/packages/plugins/integration-ever-async/node_modules ]; then cp -r /mnt/packages/plugins/integration-ever-async/node_modules ./node_modules/@gauzy/plugin-integration-ever-async/; fi && \ cp -r /mnt/packages/plugins/integration-upwork/node_modules ./node_modules/@gauzy/plugin-integration-upwork/ && \ cp -r /mnt/packages/plugins/integration-zapier/node_modules ./node_modules/@gauzy/plugin-integration-zapier/ && \ cp -r /mnt/packages/plugins/jitsu-analytics/node_modules ./node_modules/@gauzy/plugin-jitsu-analytics/ && \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67f8c79f34..42e096e59b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -317,6 +317,9 @@ jobs: PGDATABASE: migration_test run: yarn nx run core:test-postgres-migrations + - name: Test Ever Async integration boundary + run: yarn nx run plugin-integration-ever-async:test-integration + - name: Build API run: yarn build:api:prod:ci diff --git a/.scripts/ever-async-integration.test.cjs b/.scripts/ever-async-integration.test.cjs new file mode 100644 index 0000000000..b2eb413e06 --- /dev/null +++ b/.scripts/ever-async-integration.test.cjs @@ -0,0 +1,373 @@ +// cspell:ignore sqljs +const assert = require('node:assert/strict'); +const { before, after, test } = require('node:test'); +const { randomUUID } = require('node:crypto'); +const Module = require('node:module'); +require('reflect-metadata'); +require('ts-node').register({ + transpileOnly: true, + compilerOptions: { + module: 'commonjs', + moduleResolution: 'node', + experimentalDecorators: true, + emitDecoratorMetadata: true + } +}); +const { DataSource, EntitySchema } = require('typeorm'); +const { of } = require('rxjs'); +const { SetMetadata } = require('@nestjs/common'); + +// Minimal real SQL schema keeps these tests independent of the whole application bootstrap. +// Repository filters, transactions, cascaded settings and joins execute against SQLite. +const names = [ + 'IntegrationType', + 'Integration', + 'IntegrationTenant', + 'IntegrationSetting', + 'UserOrganization', + 'Employee', + 'OrganizationProject', + 'Task' +]; +const entities = Object.fromEntries(names.map((name) => [name, { [name]: class {} }[name]])); +const id = () => randomUUID(); +const tenantId = id(), + organizationId = id(), + otherOrganizationId = id(), + userId = id(); +const RequestContext = { + currentTenantId: () => tenantId, + currentOrganizationId: () => organizationId, + currentUserId: () => userId +}; +const originalLoad = Module._load; +Module._load = function (name, parent, isMain) { + if (name === '@gauzy/constants') return require('../packages/constants/src/lib/reflect-metadata.ts'); + if (name === '@gauzy/common') return require('../packages/common/src/lib/decorators/public.decorator.ts'); + if (name === '@gauzy/core') + return { + ...entities, + RequestContext, + createSsrfSafeHttpsAgent: () => new (require('node:https').Agent)(), + Public: () => SetMetadata('isPublic', true) + }; + if (name === '@gauzy/contracts') + return { + IntegrationEnum: { EVER_ASYNC: 'Ever_Async' }, + IntegrationTypeEnum: { ALL_INTEGRATIONS: 'All Integrations', PROJECT_MANAGEMENT: 'Project Management' } + }; + if (name === '@gauzy/utils') return require('../packages/utils/src/lib/ssrf-url.ts'); + return originalLoad.call(this, name, parent, isMain); +}; +const { + EverAsyncIntegrationService +} = require('../packages/plugins/integration-ever-async/src/lib/ever-async-integration.service.ts'); +const { + EverAsyncConnectorController, + EverAsyncConnectorGuard +} = require('../packages/plugins/integration-ever-async/src/lib/ever-async-connector.controller.ts'); +const { AuthGuard } = require('../packages/core/src/lib/shared/guards/auth.guard.ts'); +const { + ConfigureEverAsyncIntegrationDto +} = require('../packages/plugins/integration-ever-async/src/lib/dto/configure-ever-async-integration.dto.ts'); +const { + UpdateEverAsyncSettingsDto +} = require('../packages/plugins/integration-ever-async/src/lib/dto/update-ever-async-settings.dto.ts'); +Module._load = originalLoad; + +const base = { + id: { type: String, primary: true }, + tenantId: { type: String, nullable: true }, + organizationId: { type: String, nullable: true }, + isActive: { type: Boolean, default: true }, + isArchived: { type: Boolean, default: false } +}; +const schema = (name, columns, relations) => + new EntitySchema({ name, target: entities[name], columns: { ...base, ...columns }, relations }); +const schemas = [ + schema('IntegrationType', { name: { type: String } }), + schema( + 'Integration', + { + name: { type: String }, + provider: { type: String }, + imgSrc: { type: String }, + redirectUrl: { type: String }, + isComingSoon: { type: Boolean }, + isPaid: { type: Boolean }, + order: { type: Number } + }, + { integrationTypes: { type: 'many-to-many', target: 'IntegrationType', joinTable: true } } + ), + schema( + 'IntegrationTenant', + { name: { type: String }, integrationId: { type: String, nullable: true } }, + { settings: { type: 'one-to-many', target: 'IntegrationSetting', inverseSide: 'integration', cascade: true } } + ), + schema( + 'IntegrationSetting', + { settingsName: { type: String }, settingsValue: { type: String } }, + { integration: { type: 'many-to-one', target: 'IntegrationTenant', joinColumn: true } } + ), + schema('UserOrganization', { userId: { type: String } }), + schema('Employee', {}), + schema('OrganizationProject', { name: { type: String } }), + schema( + 'Task', + { + title: { type: String }, + status: { type: String, nullable: true }, + number: { type: Number, nullable: true }, + projectId: { type: String }, + updatedAt: { type: Date } + }, + { + members: { type: 'many-to-many', target: 'Employee', joinTable: true }, + project: { type: 'many-to-one', target: 'OrganizationProject', joinColumn: true } + } + ) +]; +let db, service, employeeId, projectId, foreignProjectId, foreignEmployeeId, taskId; +const requests = []; +before(async () => { + db = await new DataSource({ type: 'sqljs', entities: schemas, synchronize: true }).initialize(); + service = new EverAsyncIntegrationService(db, { + get: (url, options) => { + requests.push({ url, options }); + return of({ data: 'ok' }); + } + }); + employeeId = id(); + projectId = id(); + foreignProjectId = id(); + foreignEmployeeId = id(); + taskId = id(); + const scoped = { tenantId, organizationId, isActive: true, isArchived: false }; + await db.getRepository(entities.UserOrganization).save({ id: id(), ...scoped, userId }); + await db.getRepository(entities.Employee).save([ + { id: employeeId, ...scoped }, + { id: foreignEmployeeId, ...scoped, organizationId: otherOrganizationId } + ]); + await db.getRepository(entities.OrganizationProject).save([ + { id: projectId, ...scoped, name: 'Included' }, + { id: foreignProjectId, ...scoped, organizationId: otherOrganizationId, name: 'Foreign' } + ]); + await db.getRepository(entities.Task).save([ + { + id: taskId, + ...scoped, + title: 'Current task', + number: 7, + status: 'open', + projectId, + members: [{ id: employeeId }], + updatedAt: new Date() + }, + { + id: id(), + ...scoped, + title: 'Foreign task', + projectId: foreignProjectId, + organizationId: otherOrganizationId, + updatedAt: new Date() + } + ]); +}); +after(async () => { + if (db) await db.destroy(); +}); + +test('organization-scoped setup, one-time credentials, live mappings and project enforcement', async () => { + const dto = { + serverUrl: 'https://api-async.ever.co', + userMappings: [{ channel: 'slack', workspace: 'T123', chatUserId: 'U123', employeeId }], + projectIds: [projectId] + }; + await assert.rejects(service.setupIntegration(dto, otherOrganizationId), /organization/i); + await assert.rejects( + service.setupIntegration({ ...dto, projectIds: [foreignProjectId] }, organizationId), + /project/i + ); + await assert.rejects( + service.setupIntegration( + { + ...dto, + userMappings: [ + { channel: 'slack', workspace: 'T123', chatUserId: 'U123', employeeId: foreignEmployeeId } + ] + }, + organizationId + ), + /employee/i + ); + const created = await service.setupIntegration(dto, organizationId); + assert.equal(typeof created.apiKey, 'string'); + assert.equal(typeof created.apiSecret, 'string'); + const persisted = JSON.stringify(await db.getRepository(entities.IntegrationSetting).find()); + assert.ok(!persisted.includes(created.apiSecret), 'plaintext secret must never be persisted'); + const settings = await service.getSettings(organizationId); + assert.equal(settings.hasApiKey, true); + assert.ok(!JSON.stringify(settings).includes(created.apiSecret)); + await assert.rejects(service.getSettings(otherOrganizationId), /organization/i); + await assert.rejects(service.authenticateConnector(created.integrationTenantId, '', ''), /credential/i); + await assert.rejects( + service.authenticateConnector(created.integrationTenantId, created.apiKey, 'wrong'), + /credential/i + ); + await assert.rejects(service.authenticateConnector(id(), created.apiKey, created.apiSecret), /credential/i); + let scope = await service.authenticateConnector(created.integrationTenantId, created.apiKey, created.apiSecret); + assert.equal(scope.tenantId, tenantId); + assert.equal(scope.organizationId, organizationId); + assert.deepEqual( + (await service.getConnectorTasks(scope, { channel: 'slack', workspace: 'T123', chatUserId: 'U123' })).items.map( + (x) => x.id + ), + [taskId] + ); + assert.equal( + (await service.getConnectorTasks(scope, { channel: 'slack', workspace: 'T123', chatUserId: 'unmapped' })).items + .length, + 0 + ); + assert.equal((await service.getConnectorTasks(scope, { taskId })).items.length, 1); + assert.equal( + (await service.getConnectorTasks(scope, { channel: 'slack', workspace: 'OTHER', chatUserId: 'U123' })).items + .length, + 0 + ); + assert.equal( + (await service.getConnectorTasks(scope, { channel: 'discord', workspace: 'T123', chatUserId: 'U123' })).items + .length, + 0 + ); + await assert.rejects(service.getConnectorTasks(scope, { chatUserId: 'U123' }), /chat user/i); + await assert.rejects( + service.updateSettings({ userMappings: [dto.userMappings[0], dto.userMappings[0]] }, organizationId), + /one employee mapping/i + ); + await service.updateSettings( + { userMappings: [dto.userMappings[0], { ...dto.userMappings[0], workspace: 'SECOND' }] }, + organizationId + ); + scope = await service.authenticateConnector(created.integrationTenantId, created.apiKey, created.apiSecret); + assert.equal( + (await service.getConnectorTasks(scope, { channel: 'slack', workspace: 'SECOND', chatUserId: 'U123' })).items + .length, + 1 + ); + await service.updateSettings({ projectIds: [] }, organizationId); + scope = await service.authenticateConnector(created.integrationTenantId, created.apiKey, created.apiSecret); + assert.equal((await service.getConnectorTasks(scope, { taskId })).items.length, 0); + await service.updateSettings({ projectIds: [projectId], userMappings: [] }, organizationId); + scope = await service.authenticateConnector(created.integrationTenantId, created.apiKey, created.apiSecret); + assert.equal( + (await service.getConnectorTasks(scope, { channel: 'slack', workspace: 'T123', chatUserId: 'U123' })).items + .length, + 0 + ); + const rotated = await service.rotateCredentials(organizationId); + await assert.rejects( + service.authenticateConnector(created.integrationTenantId, created.apiKey, created.apiSecret), + /credential/i + ); + await service.authenticateConnector(created.integrationTenantId, rotated.apiKey, rotated.apiSecret); + await service.updateSettings({ isEnabled: false }, organizationId); + await assert.rejects( + service.authenticateConnector(created.integrationTenantId, rotated.apiKey, rotated.apiSecret), + /disabled/i + ); + await service.removeIntegration(created.integrationTenantId, organizationId); + assert.equal((await service.getStatus(organizationId)).isEnabled, false); + assert.equal(await db.getRepository(entities.Task).count(), 2, 'connector does not change tasks'); +}); + +test('verification rejects unsafe destinations and disables redirects and proxies', async () => { + for (const serverUrl of [ + 'http://example.com', + 'https://127.0.0.1', + 'https://169.254.169.254', + 'https://user:password@example.com' + ]) { + await assert.rejects(service.verifyConnection(serverUrl), /URL|HTTPS|private|credentials/i); + } + assert.equal(requests.length, 0); + assert.equal((await service.verifyConnection('https://api-async.ever.co/')).ok, true); + assert.equal(requests[0].url, 'https://api-async.ever.co/healthz'); + assert.equal(requests[0].options.maxRedirects, 0); + assert.equal(requests[0].options.proxy, false); + assert.ok(requests[0].options.httpsAgent); +}); + +test('actual HTTP guard rejects anonymous, wrong-integration and disabled connector access', async () => { + const { Test } = require('@nestjs/testing'); + const { APP_GUARD } = require('@nestjs/core'); + const module = await Test.createTestingModule({ + controllers: [EverAsyncConnectorController], + providers: [ + { provide: EverAsyncIntegrationService, useValue: service }, + EverAsyncConnectorGuard, + { provide: APP_GUARD, useClass: AuthGuard } + ] + }).compile(); + const app = module.createNestApplication({ logger: false }); + app.setGlobalPrefix('api'); + await app.listen(0, '127.0.0.1'); + try { + const key = await service.setupIntegration( + { + serverUrl: 'https://api-async.ever.co', + projectIds: [projectId], + userMappings: [{ channel: 'slack', workspace: 'T123', chatUserId: 'U123', employeeId }] + }, + organizationId + ); + const base = await app.getUrl(); + const path = base + '/api/integration/ever-async/connector'; + assert.equal((await fetch(path + '/status')).status, 401); + const headers = { + 'X-INTEGRATION-ID': key.integrationTenantId, + 'X-APP-ID': key.apiKey, + 'X-API-KEY': key.apiSecret, + 'Tenant-Id': id() + }; + const status = await fetch(path + '/status', { headers }); + assert.equal(status.status, 200); + assert.equal((await status.json()).tenantId, tenantId, 'caller headers cannot change credential scope'); + assert.equal(status.headers.get('cache-control'), 'no-store'); + const tasks = await fetch( + path + '/tasks?channel=slack&workspace=T123&chatUserId=U123&organizationId=' + otherOrganizationId, + { headers } + ); + assert.deepEqual( + (await tasks.json()).items.map((item) => item.id), + [taskId] + ); + assert.equal( + (await fetch(path + '/status', { headers: { ...headers, 'X-INTEGRATION-ID': id() } })).status, + 401 + ); + assert.equal((await fetch(path + '/tasks?chatUserId=U123&taskId=' + taskId, { headers })).status, 400); + await service.updateSettings({ isEnabled: false }, organizationId); + assert.equal((await fetch(path + '/status', { headers })).status, 403); + } finally { + await app.close(); + } +}); + +test('DTOs reject null patches, unscoped chat identities and malformed project IDs', async () => { + const { plainToInstance } = require('class-transformer'); + const { validate } = require('class-validator'); + for (const payload of [ + { userMappings: null }, + { projectIds: null }, + { isEnabled: null }, + { projectIds: ['not-a-uuid'] }, + { userMappings: [{ chatUserId: 'U123', employeeId }] } + ]) { + assert.ok((await validate(plainToInstance(UpdateEverAsyncSettingsDto, payload))).length > 0); + } + assert.ok( + (await validate(plainToInstance(ConfigureEverAsyncIntegrationDto, { serverUrl: 'http://localhost' }))).length > + 0 + ); +}); diff --git a/apps/api/config/webpack-package-copy-patterns.js b/apps/api/config/webpack-package-copy-patterns.js index 036e7e422a..74d879a182 100644 --- a/apps/api/config/webpack-package-copy-patterns.js +++ b/apps/api/config/webpack-package-copy-patterns.js @@ -43,6 +43,7 @@ const SKIP_PACKAGES = new Set([ 'integration-github-ui', 'integration-ai-ui', 'integration-activepieces-ui', + 'plugin-integration-ever-async-ui', 'docs-ui', 'dashboard-time-track-angular-ui', 'dashboard-time-track-react-ui' diff --git a/apps/api/src/assets/seed/integrations/ever-async.svg b/apps/api/src/assets/seed/integrations/ever-async.svg new file mode 100644 index 0000000000..e1871006b1 --- /dev/null +++ b/apps/api/src/assets/seed/integrations/ever-async.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/api/src/plugins.ts b/apps/api/src/plugins.ts index 2b2ae5e0f8..be8d0c8f96 100644 --- a/apps/api/src/plugins.ts +++ b/apps/api/src/plugins.ts @@ -27,6 +27,7 @@ import { IntegrationZapierPlugin } from '@gauzy/plugin-integration-zapier'; import { IntegrationActivepiecesPlugin } from '@gauzy/plugin-integration-activepieces'; import { IntegrationSimPlugin } from '@gauzy/plugin-integration-sim'; import { IntegrationPlanePlugin } from '@gauzy/plugin-integration-plane'; +import { IntegrationEverAsyncPlugin } from '@gauzy/plugin-integration-ever-async'; import { IntegrationUpworkPlugin } from '@gauzy/plugin-integration-upwork'; import { JitsuAnalyticsPlugin } from '@gauzy/plugin-jitsu-analytics'; import { JobProposalPlugin } from '@gauzy/plugin-job-proposal'; @@ -105,6 +106,7 @@ export const plugins = [ IntegrationUpworkPlugin, // Indicates the inclusion or intention to use the IntegrationPlanePlugin in the codebase. IntegrationPlanePlugin, + IntegrationEverAsyncPlugin, // Indicates the inclusion or intention to use the IntegrationSimPlugin in the codebase. IntegrationSimPlugin, // Indicates the inclusion or intention to use the IntegrationZapierPlugin in the codebase. diff --git a/apps/gauzy/src/plugin-ui.config.ts b/apps/gauzy/src/plugin-ui.config.ts index de23c3f987..fe9d07fc99 100644 --- a/apps/gauzy/src/plugin-ui.config.ts +++ b/apps/gauzy/src/plugin-ui.config.ts @@ -7,6 +7,7 @@ import { JobsPlugin } from '@gauzy/plugin-jobs-ui'; import { DocsUiPlugin } from '@gauzy/plugin-docs-ui'; import { IntegrationUpworkPlugin } from '@gauzy/plugin-integration-upwork-ui'; import { IntegrationPlanePlugin } from '@gauzy/plugin-integration-plane-ui'; +import { IntegrationEverAsyncPlugin } from '@gauzy/plugin-integration-ever-async-ui'; import { DashboardTimeTrackReactUiPlugin } from '@gauzy/plugin-dashboard-time-track-react-ui'; import { AiChatReactUiPlugin } from '@gauzy/plugin-ai-chat-react-ui'; import { DashboardTimeTrackAngularUiPlugin } from '@gauzy/plugin-dashboard-time-track-angular-ui'; @@ -77,6 +78,7 @@ export const uiPluginConfig: PluginUiConfig = { // Integration Plugins IntegrationUpworkPlugin, IntegrationPlanePlugin, + IntegrationEverAsyncPlugin, // Job Plugins JobsPlugin.init({ diff --git a/apps/server/src/package.json b/apps/server/src/package.json index 699b476e89..94f9ac8db8 100644 --- a/apps/server/src/package.json +++ b/apps/server/src/package.json @@ -183,6 +183,7 @@ "@gauzy/plugin-soundshot": "file:../../../dist/packages/plugins/soundshot", "@gauzy/plugin-integration-activepieces": "file:../../../dist/packages/plugins/integration-activepieces", "@gauzy/plugin-integration-plane": "file:../../../dist/packages/plugins/integration-plane", + "@gauzy/plugin-integration-ever-async": "file:../../../dist/packages/plugins/integration-ever-async", "@gauzy/scheduler": "file:../../../dist/packages/scheduler", "@gauzy/ui-config": "file:../../../dist/packages/ui-config", "@gauzy/utils": "file:../../../dist/packages/utils", @@ -254,6 +255,7 @@ "@gauzy/plugin-soundshot": "file:../../../dist/packages/plugins/soundshot", "@gauzy/plugin-integration-activepieces": "file:../../../dist/packages/plugins/integration-activepieces", "@gauzy/plugin-integration-plane": "file:../../../dist/packages/plugins/integration-plane", + "@gauzy/plugin-integration-ever-async": "file:../../../dist/packages/plugins/integration-ever-async", "@gauzy/scheduler": "file:../../../dist/packages/scheduler", "@gauzy/ui-config": "file:../../../dist/packages/ui-config", "@gauzy/utils": "file:../../../dist/packages/utils", diff --git a/package.json b/package.json index aa5fc59b92..7cb41262d4 100644 --- a/package.json +++ b/package.json @@ -213,9 +213,9 @@ "build:package:plugins:pre:prod": "yarn ng:prod run-many -t build -c production -p ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-dashboard-time-track-react-ui,plugin-ai-chat-react-ui", "build:package:plugins:pre:docker": "yarn ng:docker run-many -t build -c production -p ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-dashboard-time-track-react-ui,plugin-ai-chat-react-ui", "build:package:plugins:pre:dev:docker": "yarn ng:docker run-many -t build -c development -p ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-activepieces-ui,plugin-dashboard-time-track-react-ui,plugin-ai-chat-react-ui", - "build:package:plugins:post": "yarn ng run-many -t build -c development -p plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-sim,plugin-integration-plane,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:plugins:post:prod": "yarn ng:prod run-many -t build -c production -p plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-sim,plugin-integration-plane,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:plugins:post:docker": "yarn ng:docker run-many -t build -c production -p plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-sim,plugin-integration-plane,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:plugins:post": "yarn ng run-many -t build -c development -p plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:plugins:post:prod": "yarn ng:prod run-many -t build -c production -p plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:plugins:post:docker": "yarn ng:docker run-many -t build -c production -p plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", "build:package:plugin:integration-ai": "yarn ng build plugin-integration-ai", "build:package:plugin:integration-ai:prod": "yarn ng:prod build plugin-integration-ai", "build:package:plugin:integration-ai:docker": "yarn ng:docker build plugin-integration-ai", @@ -241,8 +241,11 @@ "build:package:plugin:integration-sim:prod": "yarn ng:prod build plugin-integration-sim", "build:package:plugin:integration-sim:docker": "yarn ng:docker build plugin-integration-sim", "build:package:plugin:integration-plane": "yarn ng build plugin-integration-plane", + "build:package:plugin:integration-ever-async": "yarn ng build plugin-integration-ever-async", "build:package:plugin:integration-plane:prod": "yarn ng:prod build plugin-integration-plane", + "build:package:plugin:integration-ever-async:prod": "yarn ng:prod build plugin-integration-ever-async", "build:package:plugin:integration-plane:docker": "yarn ng:docker build plugin-integration-plane", + "build:package:plugin:integration-ever-async:docker": "yarn ng:docker build plugin-integration-ever-async", "build:package:plugin:integration-zapier": "yarn ng build plugin-integration-zapier", "build:package:plugin:integration-zapier:prod": "yarn ng:prod build plugin-integration-zapier", "build:package:plugin:integration-zapier:docker": "yarn ng:docker build plugin-integration-zapier", @@ -306,13 +309,17 @@ "build:package:plugin:integration-zapier-ui:docker": "yarn ng:docker build plugin-integration-zapier-ui --configuration=production", "build:package:plugin:integration-zapier-ui:dev:docker": "yarn ng:dev:docker build plugin-integration-zapier-ui --configuration=development --verbose", "build:package:plugin:integration-plane-ui": "yarn ng build plugin-integration-plane-ui --configuration=development", + "build:package:plugin:integration-ever-async-ui": "yarn ng build plugin-integration-ever-async-ui --configuration=development", "build:package:plugin:integration-plane-ui:prod": "yarn ng:prod build plugin-integration-plane-ui --configuration=production", + "build:package:plugin:integration-ever-async-ui:prod": "yarn ng:prod build plugin-integration-ever-async-ui --configuration=production", "build:package:plugin:integration-plane-ui:docker": "yarn ng:docker build plugin-integration-plane-ui --configuration=production", + "build:package:plugin:integration-ever-async-ui:docker": "yarn ng:docker build plugin-integration-ever-async-ui --configuration=production", "build:package:plugin:integration-plane-ui:dev:docker": "yarn ng:dev:docker build plugin-integration-plane-ui --configuration=development --verbose", - "build:integration-ui-plugins": "yarn ng run-many -t build -c development -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui", - "build:integration-ui-plugins:prod": "yarn ng:prod run-many -t build -c production -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui", - "build:integration-ui-plugins:docker": "yarn ng:docker run-many -t build -c production -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui", - "build:integration-ui-plugins:dev:docker": "yarn ng:docker run-many -t build -c development -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui", + "build:package:plugin:integration-ever-async-ui:dev:docker": "yarn ng:dev:docker build plugin-integration-ever-async-ui --configuration=development --verbose", + "build:integration-ui-plugins": "yarn ng run-many -t build -c development -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui", + "build:integration-ui-plugins:prod": "yarn ng:prod run-many -t build -c production -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui", + "build:integration-ui-plugins:docker": "yarn ng:docker run-many -t build -c production -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui", + "build:integration-ui-plugins:dev:docker": "yarn ng:docker run-many -t build -c development -p plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-make-com-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui", "build:package:plugin:job-ui": "yarn ng build plugin-jobs-ui --configuration=development", "build:package:plugin:job-ui:prod": "yarn ng:prod build plugin-jobs-ui --configuration=production", "build:package:plugin:job-ui:docker": "yarn ng:docker build plugin-jobs-ui --configuration=production", @@ -390,17 +397,17 @@ "build:package:plugin:ai-chat-react-ui:dev:docker": "yarn ng:dev:docker build plugin-ai-chat-react-ui --configuration=development --verbose", "build:package:all": "yarn build:package:all:once || yarn build:package:all:once || yarn build:package:all:once", "build:package:all:prod": "yarn build:package:all:prod:once || yarn build:package:all:prod:once || yarn build:package:all:prod:once", - "build:package:all:docker": "yarn ng:docker run-many -t build --parallel=6 -c production -p constants,contracts,common,utils,config,plugin,auth,ui-config,ui-core,plugin-ui,ui-auth,core,desktop-activity,desktop-core,desktop-window,desktop-lib,desktop-ui-lib,mcp-server,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,scheduler,plugin-ai-chat-react-ui,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:api": "yarn ng run-many -t build -c development -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:api:prod": "yarn ng:prod run-many -t build -c production -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:api:docker": "yarn ng:docker run-many -t build -c production -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:api:dev:docker": "yarn ng:docker run-many -t build -c development -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:gauzy": "yarn ng run-many -t build -c development -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", - "build:package:gauzy:prod": "yarn ng:prod run-many -t build -c production -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", - "build:package:gauzy:docker": "yarn ng:docker run-many -t build -c production -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", - "build:package:gauzy:dev:docker": "yarn ng:docker run-many -t build -c development -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", - "build:package:all:once": "yarn ng run-many -t build --parallel=6 -c development -p constants,contracts,common,utils,config,plugin,auth,ui-config,ui-core,plugin-ui,ui-auth,core,desktop-activity,desktop-core,desktop-window,desktop-lib,desktop-ui-lib,mcp-server,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,scheduler,plugin-ai-chat-react-ui,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", - "build:package:all:prod:once": "yarn ng:prod run-many -t build --parallel=6 -c production -p constants,contracts,common,utils,config,plugin,auth,ui-config,ui-core,plugin-ui,ui-auth,core,desktop-activity,desktop-core,desktop-window,desktop-lib,desktop-ui-lib,mcp-server,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,scheduler,plugin-ai-chat-react-ui,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:all:docker": "yarn ng:docker run-many -t build --parallel=6 -c production -p constants,contracts,common,utils,config,plugin,auth,ui-config,ui-core,plugin-ui,ui-auth,core,desktop-activity,desktop-core,desktop-window,desktop-lib,desktop-ui-lib,mcp-server,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,scheduler,plugin-ai-chat-react-ui,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:api": "yarn ng run-many -t build -c development -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:api:prod": "yarn ng:prod run-many -t build -c production -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:api:docker": "yarn ng:docker run-many -t build -c production -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:api:dev:docker": "yarn ng:docker run-many -t build -c development -p constants,contracts,common,utils,config,plugin,auth,core,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,scheduler,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:gauzy": "yarn ng run-many -t build -c development -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", + "build:package:gauzy:prod": "yarn ng:prod run-many -t build -c production -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", + "build:package:gauzy:docker": "yarn ng:docker run-many -t build -c production -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", + "build:package:gauzy:dev:docker": "yarn ng:docker run-many -t build -c development -p constants,contracts,ui-config,ui-core,plugin-ui,ui-auth,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-activepieces-ui,plugin-integration-make-com-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui,plugin-integration-zapier-ui,plugin-ai-chat-react-ui", + "build:package:all:once": "yarn ng run-many -t build --parallel=6 -c development -p constants,contracts,common,utils,config,plugin,auth,ui-config,ui-core,plugin-ui,ui-auth,core,desktop-activity,desktop-core,desktop-window,desktop-lib,desktop-ui-lib,mcp-server,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,scheduler,plugin-ai-chat-react-ui,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", + "build:package:all:prod:once": "yarn ng:prod run-many -t build --parallel=6 -c production -p constants,contracts,common,utils,config,plugin,auth,ui-config,ui-core,plugin-ui,ui-auth,core,desktop-activity,desktop-core,desktop-window,desktop-lib,desktop-ui-lib,mcp-server,plugin-onboarding-ui,plugin-legal-ui,plugin-dashboard-time-track-angular-ui,plugin-dashboard-time-track-react-ui,plugin-job-search-ui,plugin-job-matching-ui,plugin-job-employee-ui,plugin-job-proposal-ui,plugin-jobs-ui,plugin-public-layout-ui,plugin-maintenance-ui,plugin-videos-ui,plugin-docs-ui,plugin-posthog-ui,plugin-integration-ai-ui,plugin-integration-hubstaff-ui,plugin-integration-upwork-ui,plugin-integration-github-ui,plugin-integration-zapier-ui,plugin-integration-sim-ui,plugin-integration-plane-ui,plugin-integration-ever-async-ui,plugin-integration-jira,plugin-integration-activepieces,plugin-integration-ai,plugin-sentry,plugin-posthog,plugin-jitsu-analytics,plugin-product-reviews,plugin-job-search,plugin-job-proposal,plugin-integration-github,plugin-knowledge-base,plugin-changelog,plugin-integration-hubstaff,plugin-integration-upwork,plugin-integration-make-com,plugin-videos,plugin-docs,plugin-registry,plugin-integration-zapier,plugin-soundshot,plugin-camshot,plugin-integration-wakatime,plugin-integration-sim,plugin-integration-plane,plugin-integration-ever-async,scheduler,plugin-ai-chat-react-ui,plugin-ai-chat,plugin-ai-provider-anthropic,plugin-ai-provider-openai,plugin-ai-provider-openrouter,plugin-ai-provider-vercel-gateway,plugin-ai-provider-gauzy-ai,plugin-ai-provider-gemini,plugin-ai-provider-grok,plugin-ai-provider-groq,plugin-ai-provider-mistral,plugin-ai-provider-deepgram,plugin-ai-provider-elevenlabs,plugin-ai-provider-speaches,plugin-ai-provider-localai,plugin-ai-provider-whisper-cpp,plugin-ai-provider-openai-compatible", "build:package:desktop-activity": "yarn ng build desktop-activity", "build:package:desktop-activity:prod": "yarn ng:prod build desktop-activity", "build:package:desktop-activity:docker": "yarn ng:docker build desktop-activity", diff --git a/packages/contracts/src/lib/integration.model.ts b/packages/contracts/src/lib/integration.model.ts index 68b96bc025..47ed2ac827 100644 --- a/packages/contracts/src/lib/integration.model.ts +++ b/packages/contracts/src/lib/integration.model.ts @@ -149,7 +149,8 @@ export enum IntegrationEnum { ZAPIER = 'Zapier', ACTIVE_PIECES = 'ActivePieces', SIM = 'Sim', - PLANE = 'Plane' + PLANE = 'Plane', + EVER_ASYNC = 'Ever_Async' } export enum IntegrationEntity { diff --git a/packages/core/src/lib/integration/default-integration.ts b/packages/core/src/lib/integration/default-integration.ts index 02507dad4d..ba95e49cb9 100644 --- a/packages/core/src/lib/integration/default-integration.ts +++ b/packages/core/src/lib/integration/default-integration.ts @@ -119,6 +119,15 @@ export const PROJECT_MANAGE_DEFAULT_INTEGRATIONS = [ order: 11, redirectUrl: sluggable(IntegrationEnum.PLANE), provider: IntegrationEnum.PLANE + }, + { + name: IntegrationEnum.EVER_ASYNC, + imgSrc: 'ever-async.svg', + isComingSoon: false, + integrationTypesMap: [IntegrationTypeEnum.ALL_INTEGRATIONS, IntegrationTypeEnum.PROJECT_MANAGEMENT], + order: 12, + redirectUrl: 'ever-async', + provider: IntegrationEnum.EVER_ASYNC } ]; diff --git a/packages/plugins/integration-ever-async-ui/README.md b/packages/plugins/integration-ever-async-ui/README.md index 593b5dfc09..f065734a2d 100644 --- a/packages/plugins/integration-ever-async-ui/README.md +++ b/packages/plugins/integration-ever-async-ui/README.md @@ -1,37 +1,15 @@ # @gauzy/plugin-integration-ever-async-ui -Angular UI plugin for the [Ever Async](https://github.com/ever-co/ever-async) integration — -rendered under **Integrations → Ever Async** in the Gauzy web app. +Angular configuration page for [Ever Async](https://async.ever.co), available at **Integrations → Ever Async** (`/pages/integrations/ever-async`). It uses the currently selected Gauzy organization. -> **STATUS: structural draft scaffold.** This package mirrors the file topology and conventions of -> `@gauzy/plugin-integration-plane-ui` but is not yet wired into the workspace builds. See the wiring -> checklist in the pull request description and the `// scaffold:` comments in the sources. +The page lets an authorized user select projects, map Slack or Discord identities to employees, enable or disable sharing, and generate or rotate a credential for that organization's read-only connector. Chat mappings include platform, workspace or server ID, and user ID so multiple connected workspaces remain distinct. -## What it provides +After saving, use the displayed integration details and one-time API credentials to add an Ever Gauzy connection in the chosen Ever Async tenant's **Connections** page. Select the Slack and Discord connections that should receive work context there. A self-hosted TOML example is also available. -- `IntegrationEverAsyncPlugin` — a `PluginUiDefinition` registering the - `/pages/integrations/ever-async` route at the `integrations-sections` location. -- **Connect wizard** (`EverAsyncConnectComponent`): form with the Ever Async server URL, a - write-only API token, and a **Test connection** button that pings the server's `/healthz` through - the Gauzy API (`POST /api/integration/ever-async/verify`). -- `EverAsyncService` — package-local HTTP client for the - `@gauzy/plugin-integration-ever-async` backend endpoints. +Organization changes clear settings, pending credentials, and form state before loading the new organization's data. The API independently verifies organization membership and integration permissions. Secrets are not returned by settings requests. The reachability check only verifies the Ever Async `/healthz` endpoint; it does not establish tenant pairing. -Planned follow-ups per the -[integration contract](https://github.com/ever-co/ever-async/blob/main/docs/integrations/gauzy.md): -a settings page with the chat-user → employee mapping table and per-project enable toggles. - -## Building - -Run `yarn nx build plugin-integration-ever-async-ui` to build the library (after the wiring steps in -the PR description are applied). - -## Installation - -Install the Integration Ever Async UI Plugin using your preferred package manager: - -```bash -npm install @gauzy/plugin-integration-ever-async-ui -# or -yarn add @gauzy/plugin-integration-ever-async-ui +```sh +yarn nx build plugin-integration-ever-async-ui --configuration=production ``` + +The package is included in the web application's plugin registration, TypeScript paths, workspace build scripts, and web Docker dependency manifests. See the [backend package](../integration-ever-async/README.md) for the API contract and SQL-backed verification command. diff --git a/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.html b/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.html index 19edace3c0..cbb9ab02a4 100644 --- a/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.html +++ b/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.html @@ -1,132 +1,247 @@ - +
{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.TITLE' | translate }}
- -
-
- - {{ 'INTEGRATIONS.EVER_ASYNC_PAGE.SETUP_INFO' | translate }} -
- -
- - - @if (form.get('serverUrl')?.invalid && (form.get('serverUrl')?.touched || form.get('serverUrl')?.dirty)) - { -

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.SETUP_INFO' | translate }}

+ @if (!ready()) { +

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.SELECT_ORGANIZATION' | translate }}

+ } + @if (ready()) { + +
+ + +

+ {{ 'INTEGRATIONS.EVER_ASYNC_PAGE.URL_HINT' | translate }} +

+ @if (form.controls.serverUrl.invalid && form.controls.serverUrl.touched) { + +

+ } + + @if (connectionOk() === true) { +

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.CONNECTION_OK' | translate }}

+ } + @if (connectionOk() === false) { + + } +
+
+ {{ 'INTEGRATIONS.EVER_ASYNC_PAGE.PROJECTS' | translate }} +

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.PROJECTS_HINT' | translate }}

+ @for (project of options().projects; track project.id) { + + } @empty { +

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.NO_PROJECTS' | translate }}

+ } +
+
+ {{ 'INTEGRATIONS.EVER_ASYNC_PAGE.MAPPINGS' | translate }} +

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.MAPPINGS_HINT' | translate }}

+ @for (mapping of form.controls.userMappings.controls; track mapping; let i = $index) { +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ } + +
+ @if (settings()) { + } -
- -
- - - @if (form.get('apiToken')?.invalid && (form.get('apiToken')?.touched || form.get('apiToken')?.dirty)) { - +
+ +
+ + } + @if (settings()) { +
+
{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.CONFIGURE_CONNECTOR' | translate }}
+

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.CONFIGURE_HINT' | translate }}

+
+
{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.GAUZY_API_URL' | translate }}
+
{{ gauzyApiUrl }}
+
{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.INTEGRATION_ID' | translate }}
+
{{ settings()?.integrationTenantId }}
+
{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.TENANT_ID' | translate }}
+
{{ settings()?.tenantId }}
+
{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.ORGANIZATION_ID' | translate }}
+
{{ settings()?.organizationId }}
+
+
+ {{ 'INTEGRATIONS.EVER_ASYNC_PAGE.SELF_HOSTED_CONFIG' | translate }} + + +
+ @if (credentials(); as key) { +

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.SECRET_ONCE' | translate }}

+ + + } -

- {{ 'INTEGRATIONS.EVER_ASYNC_PAGE.API_TOKEN_HINT' | translate }} -

-
- -
+

{{ 'INTEGRATIONS.EVER_ASYNC_PAGE.ROTATE_HINT' | translate }}

- - -
- +

+ {{ 'INTEGRATIONS.EVER_ASYNC_PAGE.SETUP_GUIDE' | translate }} +

+ + }
diff --git a/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.scss b/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.scss index 5cc83e07c7..8473c5b58c 100644 --- a/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.scss +++ b/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.scss @@ -1,21 +1,80 @@ +:host { + display: block; +} +form, +.connection-config { + max-width: 64rem; +} .hint { - margin-bottom: 24px; + margin: 0.75rem 0; + color: var(--text-hint-color); } - -.actions { - display: flex; - gap: 8px; - margin-top: 16px; -} - -button { - box-shadow: var(--gauzy-shadow); +fieldset, +.connection-config { + margin: 1.5rem 0; + padding: 1rem; + border: 1px solid var(--border-basic-color-3); border-radius: var(--border-radius); } +legend { + width: auto; + font-size: 1rem; + padding: 0 0.4rem; +} +.mapping-row { + display: grid; + grid-template-columns: 0.7fr 1fr 1fr 1fr auto; + align-items: end; + gap: 0.75rem; + margin-bottom: 1rem; +} +.project-option { + display: flex; + align-items: center; + gap: 0.6rem; + margin: 0.75rem 0; +} +select { + width: 100%; + min-height: 2.7rem; + border: 1px solid var(--border-basic-color-3); + border-radius: var(--border-radius); + color: var(--text-basic-color); + background: var(--background-basic-color-1); + padding: 0.5rem; +} +.actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} +.connection-config label { + display: block; + margin-top: 0.75rem; +} +.connection-config button { + margin-top: 0.75rem; +} +textarea { + font-family: monospace; + resize: vertical; +} +@media (max-width: 640px) { + .mapping-row { + grid-template-columns: 1fr; + } +} -:host { - nb-card, - nb-card-body { - background-color: var(--gauzy-card-2); +.pairing-details dd { + overflow-wrap: anywhere; +} +@media (max-width: 1100px) { + .mapping-row { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 640px) { + .mapping-row { + grid-template-columns: 1fr; } } diff --git a/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.ts b/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.ts index 04132502aa..f3c583e96d 100644 --- a/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.ts +++ b/packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.ts @@ -1,27 +1,22 @@ import { Component, OnInit, signal, inject, ChangeDetectionStrategy } from '@angular/core'; import { Location } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; -import { IOrganization } from '@gauzy/contracts'; +import { catchError, distinctUntilChanged, forkJoin, of, switchMap, tap, throwError } from 'rxjs'; +import { ID, IOrganization, PermissionsEnum } from '@gauzy/contracts'; +import { API_PREFIX } from '@gauzy/ui-core/common'; import { ErrorHandlingService, Store, ToastrService } from '@gauzy/ui-core/core'; import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; import { EverAsyncService, + IEverAsyncOptions, + IEverAsyncSettingsResponse, IEverAsyncSetupResponse, - IEverAsyncVerifyResponse + IEverAsyncUserMapping } from '../../services/ever-async.service'; -const URL_PATTERN = /^https?:\/\/.+/; - -/** - * Connect wizard for the Ever Async integration: - * paste the Ever Async server URL + API token, test the connection - * (`/healthz` ping via the Gauzy API), then save. - */ @UntilDestroy({ checkProperties: true }) @Component({ selector: 'ngx-ever-async-connect', @@ -31,111 +26,240 @@ const URL_PATTERN = /^https?:\/\/.+/; changeDetection: ChangeDetectionStrategy.OnPush }) export class EverAsyncConnectComponent extends TranslationBaseComponent implements OnInit { - private readonly _location = inject(Location); - private readonly _router = inject(Router); - private readonly _store = inject(Store); - private readonly _everAsyncService = inject(EverAsyncService); - private readonly _toastrService = inject(ToastrService); - private readonly _errorHandlingService = inject(ErrorHandlingService); - + private readonly store = inject(Store); + private readonly service = inject(EverAsyncService); + private readonly location = inject(Location); + private readonly errors = inject(ErrorHandlingService); + private readonly toastr = inject(ToastrService); readonly organization = signal(null); - readonly loading = signal(false); - readonly verifying = signal(false); - /** null = not tested yet; true/false = last test-connection outcome. */ + readonly loading = signal(false); + readonly verifying = signal(false); readonly connectionOk = signal(null); - - form = new FormGroup({ - serverUrl: new FormControl('', [Validators.required, Validators.pattern(URL_PATTERN)]), - apiToken: new FormControl('', [Validators.required]) + readonly settings = signal(null); + readonly credentials = signal(null); + readonly options = signal({ employees: [], projects: [] }); + readonly showSecret = signal(false); + readonly ready = signal(false); + readonly form = new FormGroup({ + serverUrl: new FormControl('https://api-async.ever.co', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https:\/\/[^\s]+$/)] + }), + projectIds: new FormControl([], { nonNullable: true }), + isEnabled: new FormControl(true, { nonNullable: true }), + userMappings: new FormArray< + FormGroup<{ + channel: FormControl<'slack' | 'discord'>; + workspace: FormControl; + chatUserId: FormControl; + employeeId: FormControl; + }> + >([]) }); constructor(readonly translateService: TranslateService) { super(translateService); } + get canSave() { + return this.store.hasPermission( + this.settings() ? PermissionsEnum.INTEGRATION_EDIT : PermissionsEnum.INTEGRATION_ADD + ); + } + get canRotate() { + return this.store.hasPermission(PermissionsEnum.INTEGRATION_EDIT); + } ngOnInit(): void { - this._store.selectedOrganization$ + this.store.selectedOrganization$ .pipe( - filter((org): org is IOrganization => !!org), - tap((org) => this.organization.set(org)), + distinctUntilChanged((a, b) => a?.id === b?.id), + tap((org) => { + this.organization.set(org ?? null); + this.settings.set(null); + this.credentials.set(null); + this.options.set({ employees: [], projects: [] }); + this.ready.set(false); + this.loading.set(false); + this.connectionOk.set(null); + this.showSecret.set(false); + this.form.reset({ serverUrl: 'https://api-async.ever.co', projectIds: [], isEnabled: true }); + this.form.controls.userMappings.clear(); + }), + switchMap((org) => + org?.id + ? forkJoin({ + options: this.service.getOptions(org.id), + settings: this.service + .getSettings(org.id) + .pipe( + catchError((error: HttpErrorResponse) => + error.status === 404 ? of(null) : throwError(() => error) + ) + ) + }).pipe( + catchError((error) => { + this.errors.handleError(error); + return of(null); + }) + ) + : of(null) + ), untilDestroyed(this) ) - .subscribe(); + .subscribe((result) => { + if (!result) return; + this.options.set(result.options); + this.settings.set(result.settings); + this.ready.set(true); + if (result.settings) { + this.form.patchValue(result.settings); + for (const mapping of result.settings.userMappings) this.addMapping(mapping); + } + }); + this.form.controls.serverUrl.valueChanges + .pipe(untilDestroyed(this)) + .subscribe(() => this.connectionOk.set(null)); } - goBack(): void { - this._location.back(); + goBack() { + this.location.back(); + } + addMapping(mapping?: IEverAsyncUserMapping) { + this.form.controls.userMappings.push( + new FormGroup({ + channel: new FormControl<'slack' | 'discord'>(mapping?.channel ?? 'slack', { + nonNullable: true, + validators: [Validators.required] + }), + workspace: new FormControl(mapping?.workspace ?? '', { + nonNullable: true, + validators: [Validators.required, Validators.maxLength(200), Validators.pattern(/^\S+$/)] + }), + chatUserId: new FormControl(mapping?.chatUserId ?? '', { + nonNullable: true, + validators: [Validators.required, Validators.maxLength(200), Validators.pattern(/^\S+$/)] + }), + employeeId: new FormControl(mapping?.employeeId ?? '', { + nonNullable: true, + validators: [Validators.required] + }) + }) + ); + } + removeMapping(index: number) { + this.form.controls.userMappings.removeAt(index); + } + toggleProject(id: ID) { + const ids = this.form.controls.projectIds.value; + this.form.controls.projectIds.setValue(ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id]); } - /** - * Ping the Ever Async server's /healthz endpoint (via the Gauzy API) - * without saving anything. - */ - testConnection(): void { - if (this.verifying()) return; - - const serverUrl = this.form.get('serverUrl')?.value?.trim() ?? ''; - if (!serverUrl || !URL_PATTERN.test(serverUrl)) { - this.form.get('serverUrl')?.markAsTouched(); + testConnection() { + const serverUrl = this.form.controls.serverUrl.value.trim(); + if (this.verifying() || this.form.controls.serverUrl.invalid) { + this.form.controls.serverUrl.markAsTouched(); return; } - this.verifying.set(true); this.connectionOk.set(null); - - this._everAsyncService + this.service .verify(serverUrl) .pipe(untilDestroyed(this)) .subscribe({ - next: (result: IEverAsyncVerifyResponse) => { + next: (result) => { this.verifying.set(false); - this.connectionOk.set(result.ok); - this._toastrService.success(this.getTranslation('INTEGRATIONS.EVER_ASYNC_PAGE.CONNECTION_OK')); + if (this.form.controls.serverUrl.value.trim() === serverUrl) this.connectionOk.set(result.ok); }, - error: (error: HttpErrorResponse) => { + error: (error) => { this.verifying.set(false); this.connectionOk.set(false); - this._toastrService.danger(this.getTranslation('INTEGRATIONS.EVER_ASYNC_PAGE.CONNECTION_FAILED')); - this._errorHandlingService.handleError(error); + this.errors.handleError(error); } }); } - /** - * Save the connection: server URL + write-only API token. - * User mappings are managed later from the settings page (scaffold: the - * mapping table component is a follow-up, see the integration contract). - */ - connect(): void { - if (this.loading()) return; - + connect() { const organizationId = this.organization()?.id; - if (!organizationId) return; - - const serverUrl = this.form.get('serverUrl')?.value?.trim() ?? ''; - const apiToken = this.form.get('apiToken')?.value?.trim() ?? ''; - - this.form.patchValue({ serverUrl, apiToken }); + if (!organizationId || !this.ready() || this.loading() || !this.canSave) return; if (this.form.invalid) { this.form.markAllAsTouched(); return; } - + const dto = this.form.getRawValue(); + dto.serverUrl = dto.serverUrl.trim(); this.loading.set(true); + if (this.settings()) { + this.service + .updateSettings(dto, organizationId) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + if (this.organization()?.id !== organizationId) return; + this.loading.set(false); + this.settings.update((value) => (value ? { ...value, ...dto } : value)); + this.saved(); + }, + error: (error) => this.failed(error, organizationId) + }); + } else { + this.service + .setup(dto, organizationId) + .pipe(untilDestroyed(this)) + .subscribe({ + next: (result) => { + if (this.organization()?.id !== organizationId) return; + this.loading.set(false); + this.credentials.set(result); + this.settings.set({ + ...dto, + integrationTenantId: result.integrationTenantId, + tenantId: result.tenantId, + organizationId: result.organizationId, + hasApiKey: true + }); + this.saved(); + }, + error: (error) => this.failed(error, organizationId) + }); + } + } - this._everAsyncService - .setup({ serverUrl, apiToken }, organizationId) + rotateCredentials() { + const organizationId = this.organization()?.id; + if (!organizationId || !this.canRotate || this.loading()) return; + this.loading.set(true); + this.credentials.set(null); + this.showSecret.set(false); + this.service + .rotateCredentials(organizationId) .pipe(untilDestroyed(this)) .subscribe({ - next: (_result: IEverAsyncSetupResponse) => { + next: (result) => { + if (this.organization()?.id !== organizationId) return; this.loading.set(false); - this._toastrService.success(this.getTranslation('INTEGRATIONS.EVER_ASYNC_PAGE.CONNECTED')); - this._router.navigate(['/pages/integrations']); + this.credentials.set(result); }, - error: (error: HttpErrorResponse) => { - this.loading.set(false); - this._errorHandlingService.handleError(error); - } + error: (error) => this.failed(error, organizationId) }); } + + private saved() { + this.toastr.success(this.getTranslation('INTEGRATIONS.EVER_ASYNC_PAGE.SAVED')); + } + private failed(error: HttpErrorResponse, organizationId: ID) { + if (this.organization()?.id !== organizationId) return; + this.loading.set(false); + this.errors.handleError(error); + } + + get gauzyApiUrl(): string { + return new URL(API_PREFIX, window.location.origin).toString().replace(/\/api\/?$/, ''); + } + + get connectorConfig(): string { + const settings = this.settings(); + if (!settings) return ''; + const apiUrl = this.gauzyApiUrl; + return `[connectors.gauzy]\nbase_url = ${JSON.stringify(apiUrl)}\napp_base_url = ${JSON.stringify(window.location.origin)}\nintegration_id = ${JSON.stringify(settings.integrationTenantId)}\ntenant_id = ${JSON.stringify(settings.tenantId)}\norganization_id = ${JSON.stringify(settings.organizationId)}\napi_key_env = "GAUZY_ASYNC_API_KEY"\napi_secret_env = "GAUZY_ASYNC_API_SECRET"\nasync_tenant_id = "YOUR_ASYNC_TENANT"\nchannel = "slack"\nworkspace = "YOUR_CHAT_WORKSPACE"`; + } } diff --git a/packages/plugins/integration-ever-async-ui/src/lib/services/ever-async.service.ts b/packages/plugins/integration-ever-async-ui/src/lib/services/ever-async.service.ts index 75e1e4aaa1..9d8fd2355f 100644 --- a/packages/plugins/integration-ever-async-ui/src/lib/services/ever-async.service.ts +++ b/packages/plugins/integration-ever-async-ui/src/lib/services/ever-async.service.ts @@ -1,118 +1,90 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; import { ID } from '@gauzy/contracts'; +import { Observable } from 'rxjs'; import { API_PREFIX } from '@gauzy/ui-core/common'; -/** - * HTTP client for the `@gauzy/plugin-integration-ever-async` backend - * (`/api/integration/ever-async`). - * - * scaffold: kept package-local so this PR stays purely additive. When wiring, - * consider promoting it to `packages/ui-core/core/src/lib/services/ever-async/` - * (mirroring `PlaneService`) and exporting it from `@gauzy/ui-core/core`. - */ - export interface IEverAsyncUserMapping { + channel: 'slack' | 'discord'; + workspace: string; chatUserId: string; employeeId: ID; } - export interface IEverAsyncSetupRequest { serverUrl: string; - /** Write-only: stored by the backend, never returned by read endpoints. */ - apiToken: string; - userMappings?: IEverAsyncUserMapping[]; + userMappings: IEverAsyncUserMapping[]; + projectIds: ID[]; } - export interface IEverAsyncSetupResponse { integrationTenantId: ID; + tenantId: ID; + organizationId: ID; + apiKey: string; + apiSecret: string; } - -export interface IEverAsyncSettingsResponse { +export interface IEverAsyncSettingsResponse extends IEverAsyncSetupRequest { integrationTenantId: ID; - serverUrl: string; - userMappings: IEverAsyncUserMapping[]; + tenantId: ID; + organizationId: ID; isEnabled: boolean; - hasApiToken: boolean; + hasApiKey: boolean; } - export interface IEverAsyncStatusResponse { isEnabled: boolean; integrationTenantId: ID | null; } - export interface IEverAsyncUpdateResponse { integrationTenantId: ID; updated: boolean; } - export interface IEverAsyncVerifyResponse { ok: boolean; serverUrl: string; } +export interface IEverAsyncOptions { + employees: { id: ID; name: string }[]; + projects: { id: ID; name: string }[]; +} @Injectable({ providedIn: 'root' }) export class EverAsyncService { - private readonly API_URL = `${API_PREFIX}/integration/ever-async`; - + private readonly apiUrl = `${API_PREFIX}/integration/ever-async`; constructor(private readonly http: HttpClient) {} - - /** - * Configure the Ever Async integration (server URL + write-only API token + mappings). - */ - setup(dto: IEverAsyncSetupRequest, organizationId?: string): Observable { - let params = new HttpParams(); - if (organizationId) { - params = params.set('organizationId', organizationId); - } - return this.http.post(`${this.API_URL}/setup`, dto, { params }); + private options(organizationId: ID) { + return { params: new HttpParams().set('organizationId', organizationId) }; } - - /** - * Get the current settings (the API token is never returned). - */ - getSettings(organizationId?: string): Observable { - let params = new HttpParams(); - if (organizationId) { - params = params.set('organizationId', organizationId); - } - return this.http.get(`${this.API_URL}/settings`, { params }); + setup(dto: IEverAsyncSetupRequest, organizationId: ID): Observable { + return this.http.post(`${this.apiUrl}/setup`, dto, this.options(organizationId)); + } + getSettings(organizationId: ID): Observable { + return this.http.get(`${this.apiUrl}/settings`, this.options(organizationId)); + } + getOptions(organizationId: ID): Observable { + return this.http.get(`${this.apiUrl}/options`, this.options(organizationId)); } - - /** - * Partially update the settings. - */ updateSettings( - dto: Partial, - organizationId?: string + dto: Partial & { isEnabled?: boolean }, + organizationId: ID ): Observable { - let params = new HttpParams(); - if (organizationId) { - params = params.set('organizationId', organizationId); - } - return this.http.put(`${this.API_URL}/settings`, dto, { params }); + return this.http.put(`${this.apiUrl}/settings`, dto, this.options(organizationId)); } - - /** - * Verify connectivity to an Ever Async server (`/healthz` ping). - * Omit `serverUrl` to verify the stored configuration. - */ - verify(serverUrl?: string): Observable { - return this.http.post(`${this.API_URL}/verify`, serverUrl ? { serverUrl } : {}); + rotateCredentials(organizationId: ID): Observable { + return this.http.post( + `${this.apiUrl}/credentials/rotate`, + {}, + this.options(organizationId) + ); } - - /** - * Integration status for the current tenant. - */ - getStatus(): Observable { - return this.http.get(`${this.API_URL}/status`); + verify(serverUrl: string): Observable { + return this.http.post(`${this.apiUrl}/verify`, { serverUrl }); } - - /** - * Remove (soft-archive) the integration. - */ - remove(integrationTenantId: ID): Observable<{ success: boolean }> { - return this.http.delete<{ success: boolean }>(`${this.API_URL}/${integrationTenantId}`); + getStatus(organizationId: ID): Observable { + return this.http.get(`${this.apiUrl}/status`, this.options(organizationId)); + } + remove(integrationTenantId: ID, organizationId: ID): Observable<{ success: boolean }> { + return this.http.delete<{ success: boolean }>( + `${this.apiUrl}/${integrationTenantId}`, + this.options(organizationId) + ); } } diff --git a/packages/plugins/integration-ever-async/README.md b/packages/plugins/integration-ever-async/README.md index ff3bd2efc8..e9f0765106 100644 --- a/packages/plugins/integration-ever-async/README.md +++ b/packages/plugins/integration-ever-async/README.md @@ -1,56 +1,53 @@ # @gauzy/plugin-integration-ever-async -Ever Gauzy Platform plugin for integration with [Ever Async](https://github.com/ever-co/ever-async) — -an open-source, async-first team communication assistant that brings work context (tasks, projects) -from tools like Gauzy, Jira and GitHub into chat platforms (Slack/Discord). +Connect an Ever Gauzy organization to an [Ever Async](https://async.ever.co) tenant. The connector can read task links and status from explicitly selected projects. Its credentials cannot modify tasks or access general Gauzy APIs. -> **STATUS: structural draft scaffold.** This package mirrors the file topology and conventions of -> `@gauzy/plugin-integration-plane` but is not yet wired into the workspace builds. See the wiring -> checklist in the pull request description and the `// scaffold:` comments in the sources. +## Configure the integration -## Integration contract +1. In Gauzy, select the organization and open **Integrations → Ever Async**. +2. Enter the Ever Async API URL, select the projects to share, and map chat identities to active employees. Each identity includes its platform, workspace or server ID, and user ID. Use the verified workspace ID shown in Ever Async **Connections**. +3. Save. Gauzy displays an integration ID, tenant ID, organization ID, API key, and one-time API secret. +4. Sign in to Ever Async, select the tenant, and open **Connections**. Add an Ever Gauzy connection using those details and the Gauzy API URL. Choose which of that tenant's Slack and Discord connections may use the work context. -The two halves of the integration are described in the Ever Async repo: -[`docs/integrations/gauzy.md`](https://github.com/ever-co/ever-async/blob/main/docs/integrations/gauzy.md) +An empty project selection shares no tasks. An unmapped author receives no inferred employee task context. Explicit task links still have to belong to a selected, active project in the configured Gauzy organization. A user ID from a different platform or workspace is a different identity. -- **Ever Async side** (`crates/plugins/connector-gauzy` in `ever-co/ever-async`): a `ConnectorPlugin` - that consumes the Gauzy REST API (Bearer token + `Tenant-Id` header) to resolve chat messages like - "my last task" into task links + status. -- **Gauzy side** (this plugin pair): stores the Ever Async server URL, manages the API token the - connector uses (write-only), and manages the chat-user → employee mapping that the connector's - `[connectors.gauzy.user_map]` config consumes. The companion Angular package - `@gauzy/plugin-integration-ever-async-ui` renders the connect wizard under - **Integrations → Ever Async**. +Changing mappings or the project selection takes effect on the next connector request. Disabling the integration rejects connector access. Rotating credentials immediately invalidates the previous key and secret; update the corresponding Ever Async connection with the new credentials. Archiving an integration preserves its history. -## What this plugin provides +**Test connection** checks the Ever Async server's `/healthz` response. It does not install a chat application or prove that tenant pairing is configured. -REST endpoints under `/api/integration/ever-async` (guarded by `TenantPermissionGuard` + -`PermissionGuard`, mirroring the Plane integration): +## API boundary -| Method | Path | Permission | Purpose | -| ------ | ----------------------- | -------------------- | ------------------------------------------------------ | -| POST | `/setup` | `INTEGRATION_ADD` | Store server URL + API token (write-only) + mappings | -| GET | `/settings` | `INTEGRATION_VIEW` | Read settings (token never returned, only `hasApiToken`) | -| PUT | `/settings` | `INTEGRATION_EDIT` | Partial update of URL / token / mappings | -| POST | `/verify` | `INTEGRATION_VIEW` | Ping the Ever Async server's `/healthz` | -| GET | `/status` | `INTEGRATION_VIEW` | Is the integration enabled for this tenant? | -| DELETE | `/:integrationTenantId` | `INTEGRATION_DELETE` | Soft-archive the integration | +Management routes use the signed-in Gauzy user, tenant permissions, integration permissions, and active organization membership. Supply `organizationId` as a query parameter; it must belong to the authenticated tenant and user. -Settings are persisted as `IntegrationSetting` rows (see `EverAsyncSettingName`): -`EVER_ASYNC_SERVER_URL`, `EVER_ASYNC_API_TOKEN` (write-only), `EVER_ASYNC_USER_MAPPINGS` -(JSON array of `{ chatUserId, employeeId }`), `IS_ENABLED`. +| Method | Path under `/api/integration/ever-async` | Permission | Result | +| ------ | ---------------------------------------- | -------------------- | --------------------------------------------------------- | +| POST | `/setup` | `INTEGRATION_ADD` | Save settings and return one-time credentials | +| GET | `/settings` | `INTEGRATION_VIEW` | Read settings and credential presence, never the secret | +| GET | `/options` | `INTEGRATION_VIEW` | Active employees and projects in this organization | +| PUT | `/settings` | `INTEGRATION_EDIT` | Update URL, mappings, selected projects, or enabled state | +| POST | `/credentials/rotate` | `INTEGRATION_EDIT` | Replace credentials and return the new secret once | +| POST | `/verify` | `INTEGRATION_VIEW` | Check a public HTTPS Ever Async server | +| GET | `/status` | `INTEGRATION_VIEW` | Read this organization's integration status | +| DELETE | `/:integrationTenantId` | `INTEGRATION_DELETE` | Soft-archive this organization's integration | -## Building +The separate read-only connector routes require all three headers: `X-INTEGRATION-ID`, `X-APP-ID` (API key), and `X-API-KEY` (API secret). They bypass the user JWT guard but always run the dedicated connector credential guard. The stored credential determines tenant and organization; request headers and query parameters cannot override that scope. -Run `yarn nx build plugin-integration-ever-async` to build the library (after the wiring steps in the -PR description are applied). +- `GET /connector/status` returns the authenticated integration, tenant, and organization IDs. +- `GET /connector/tasks?channel=slack&workspace=T123&chatUserId=U123` resolves the saved employee mapping. +- `GET /connector/tasks?taskId=` resolves an explicit task link. -## Installation +Supply exactly one of `chatUserId` or `taskId`. Responses include at most ten task summaries and use `Cache-Control: no-store`. Only a SHA-256 digest of the random API secret is stored in `IntegrationSetting`; plaintext secrets are returned only by setup and rotation. -Install the Integration Ever Async Plugin using your preferred package manager: +## Build and verification -```bash -npm install @gauzy/plugin-integration-ever-async -# or -yarn add @gauzy/plugin-integration-ever-async +```sh +yarn nx build plugin-integration-ever-async --configuration=production +yarn nx build plugin-integration-ever-async-ui --configuration=production +yarn nx run plugin-integration-ever-async:test-integration ``` + +Both packages participate in the workspace and Docker builds. The backend is registered in the API plugin list and packaged into the API/worker images. It owns no background queue, so the worker does not bootstrap it. The Angular package is registered in the web application and excluded from backend runtime package copies. The desktop server manifest includes the backend package. + +The integration catalog is seeded for new installations and registered during application bootstrap for existing installations. It uses existing integration tables. SQL-backed tests exercise organization membership, project and employee restrictions, workspace identity isolation, credential storage/rotation, disabled access, and the actual HTTP credential guard. + +For a self-hosted Ever Async server, the Gauzy page also provides an optional TOML example. Hosted connections are configured per tenant through the Ever Async dashboard. diff --git a/packages/plugins/integration-ever-async/package.json b/packages/plugins/integration-ever-async/package.json index 0c0c4dc449..1b195a6ef4 100644 --- a/packages/plugins/integration-ever-async/package.json +++ b/packages/plugins/integration-ever-async/package.json @@ -32,6 +32,7 @@ "@gauzy/contracts": "^0.1.0", "@gauzy/core": "^0.1.0", "@gauzy/plugin": "^0.1.0", + "@gauzy/utils": "^0.1.0", "@nestjs/axios": "github:ever-co/nestjs-axios#master", "@nestjs/swagger": "^11.4.4", "axios": "^1.18.0", diff --git a/packages/plugins/integration-ever-async/project.json b/packages/plugins/integration-ever-async/project.json index b3b527662e..356d911dab 100644 --- a/packages/plugins/integration-ever-async/project.json +++ b/packages/plugins/integration-ever-async/project.json @@ -31,12 +31,20 @@ "lint": { "executor": "@nx/eslint:lint" }, + "test-integration": { + "executor": "nx:run-commands", + "options": { + "command": "node --test .scripts/ever-async-integration.test.cjs" + }, + "cache": false + }, "test": { - "executor": "@nx/jest:jest", + "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { - "jestConfig": "packages/plugins/integration-ever-async/jest.config.ts" - } + "command": "node --test .scripts/ever-async-integration.test.cjs" + }, + "cache": false } } } diff --git a/packages/plugins/integration-ever-async/src/lib/dto/configure-ever-async-integration.dto.ts b/packages/plugins/integration-ever-async/src/lib/dto/configure-ever-async-integration.dto.ts index e9ba181165..c9640080a3 100644 --- a/packages/plugins/integration-ever-async/src/lib/dto/configure-ever-async-integration.dto.ts +++ b/packages/plugins/integration-ever-async/src/lib/dto/configure-ever-async-integration.dto.ts @@ -1,41 +1,38 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsNotEmpty, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsNotEmpty, + ValidateIf, + IsString, + IsUrl, + IsUUID, + ValidateNested +} from 'class-validator'; import { EverAsyncUserMappingDto } from './ever-async-user-mapping.dto'; -/** - * DTO for configuring the Ever Async integration for the current tenant. - * - * Tenant/organization context is NOT part of the payload: the tenant comes from - * `RequestContext.currentTenantId()` and the organization from the - * `organizationId` query parameter (same convention as the Plane integration). - */ export class ConfigureEverAsyncIntegrationDto { - @ApiProperty({ - description: 'Base URL of the Ever Async server to connect to', - example: 'https://async.example.com' - }) + @ApiProperty({ description: 'Public HTTPS URL of the Ever Async server', example: 'https://api-async.ever.co' }) @IsNotEmpty() @IsString() - @IsUrl({ require_tld: false, require_protocol: true }) + @IsUrl({ protocols: ['https'], require_protocol: true }) readonly serverUrl!: string; - @ApiProperty({ - description: - 'API token used by Gauzy to call the Ever Async server. Write-only: it is stored as an integration setting and never returned by any read endpoint.', - example: 'eva_xxxxxxxxxxxxxxxxxxxx' - }) - @IsNotEmpty() - @IsString() - readonly apiToken!: string; - - @ApiPropertyOptional({ - description: 'Chat user → Gauzy employee mappings consumed by the Ever Async Gauzy connector', - type: [EverAsyncUserMappingDto] - }) - @IsOptional() + @ApiPropertyOptional({ type: [EverAsyncUserMappingDto] }) + @ValidateIf((_object, value) => value !== undefined) @IsArray() + @ArrayMaxSize(1000) @ValidateNested({ each: true }) @Type(() => EverAsyncUserMappingDto) readonly userMappings?: EverAsyncUserMappingDto[]; + + @ApiPropertyOptional({ description: 'Projects whose tasks may be read. Empty means no tasks.', type: [String] }) + @ValidateIf((_object, value) => value !== undefined) + @IsArray() + @ArrayMaxSize(1000) + @ArrayUnique() + @IsUUID('all', { each: true }) + readonly projectIds?: string[]; } diff --git a/packages/plugins/integration-ever-async/src/lib/dto/ever-async-user-mapping.dto.ts b/packages/plugins/integration-ever-async/src/lib/dto/ever-async-user-mapping.dto.ts index b4b46ba556..d61eeb8271 100644 --- a/packages/plugins/integration-ever-async/src/lib/dto/ever-async-user-mapping.dto.ts +++ b/packages/plugins/integration-ever-async/src/lib/dto/ever-async-user-mapping.dto.ts @@ -1,21 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { IsIn, IsNotEmpty, IsString, IsUUID, MaxLength, Matches } from 'class-validator'; -/** - * A single chat-user → Gauzy employee mapping. - * - * Mirrors one entry of `[connectors.gauzy.user_map]` in the Ever Async - * connector config (`everasync.toml`): the Ever Async connector maps the chat - * message author's platform user id (Slack/Discord) to a Gauzy employee id - * before querying that employee's tasks. - */ +/** A workspace-scoped chat identity mapped to an employee in this Gauzy organization. */ export class EverAsyncUserMappingDto { + @ApiProperty({ enum: ['slack', 'discord'] }) + @IsIn(['slack', 'discord']) + readonly channel!: 'slack' | 'discord'; + + @ApiProperty({ description: 'Verified Slack workspace ID or Discord server ID from Ever Async Connections' }) + @IsString() + @Matches(/^[^\s]{1,200}$/) + readonly workspace!: string; + @ApiProperty({ description: 'Chat platform user id (e.g. a Slack member id)', example: 'U0123ABC' }) @IsNotEmpty() @IsString() + @MaxLength(200) + @Matches(/^[^\s]+$/) readonly chatUserId!: string; @ApiProperty({ diff --git a/packages/plugins/integration-ever-async/src/lib/dto/update-ever-async-settings.dto.ts b/packages/plugins/integration-ever-async/src/lib/dto/update-ever-async-settings.dto.ts index 6fbf64c1da..0283f5faf1 100644 --- a/packages/plugins/integration-ever-async/src/lib/dto/update-ever-async-settings.dto.ts +++ b/packages/plugins/integration-ever-async/src/lib/dto/update-ever-async-settings.dto.ts @@ -1,9 +1,12 @@ -import { PartialType } from '@nestjs/swagger'; +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsBoolean, ValidateIf } from 'class-validator'; import { ConfigureEverAsyncIntegrationDto } from './configure-ever-async-integration.dto'; -/** - * DTO for updating Ever Async integration settings. - * All fields are optional (partial update). `apiToken`, when provided, - * replaces the stored token (write-only — never returned). - */ -export class UpdateEverAsyncSettingsDto extends PartialType(ConfigureEverAsyncIntegrationDto) {} +export class UpdateEverAsyncSettingsDto extends PartialType(ConfigureEverAsyncIntegrationDto, { + skipNullProperties: false +}) { + @ApiPropertyOptional() + @ValidateIf((_object, value) => value !== undefined) + @IsBoolean() + readonly isEnabled?: boolean; +} diff --git a/packages/plugins/integration-ever-async/src/lib/dto/verify-ever-async-connection.dto.ts b/packages/plugins/integration-ever-async/src/lib/dto/verify-ever-async-connection.dto.ts index bc248959df..afa01d0588 100644 --- a/packages/plugins/integration-ever-async/src/lib/dto/verify-ever-async-connection.dto.ts +++ b/packages/plugins/integration-ever-async/src/lib/dto/verify-ever-async-connection.dto.ts @@ -1,20 +1,9 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUrl } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUrl } from 'class-validator'; -/** - * DTO for verifying connectivity to an Ever Async server. - * - * `serverUrl` is optional: when omitted, the stored - * `EVER_ASYNC_SERVER_URL` setting for the current tenant is used, so the UI can - * test either a not-yet-saved URL (connect wizard) or the saved configuration. - */ export class VerifyEverAsyncConnectionDto { - @ApiPropertyOptional({ - description: 'Ever Async server base URL to verify (defaults to the stored setting)', - example: 'https://async.example.com' - }) - @IsOptional() - @IsString() - @IsUrl({ require_tld: false, require_protocol: true }) - readonly serverUrl?: string; + @ApiProperty({ example: 'https://api-async.ever.co' }) + @IsNotEmpty() + @IsUrl({ protocols: ['https'], require_protocol: true }) + readonly serverUrl!: string; } diff --git a/packages/plugins/integration-ever-async/src/lib/ever-async-connector.controller.ts b/packages/plugins/integration-ever-async/src/lib/ever-async-connector.controller.ts new file mode 100644 index 0000000000..73abb57103 --- /dev/null +++ b/packages/plugins/integration-ever-async/src/lib/ever-async-connector.controller.ts @@ -0,0 +1,63 @@ +import { + CanActivate, + Controller, + ExecutionContext, + Get, + Injectable, + Query, + Req, + UseGuards, + Header +} from '@nestjs/common'; +import { ApiHeader, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { Public } from '@gauzy/common'; +import { EverAsyncConnectorScope, EverAsyncIntegrationService } from './ever-async-integration.service'; + +type ConnectorRequest = Request & { everAsyncScope: EverAsyncConnectorScope }; + +@Injectable() +export class EverAsyncConnectorGuard implements CanActivate { + constructor(private readonly service: EverAsyncIntegrationService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + request.everAsyncScope = await this.service.authenticateConnector( + request.get('X-INTEGRATION-ID') ?? '', + request.get('X-APP-ID') ?? '', + request.get('X-API-KEY') ?? '' + ); + return true; + } +} + +/** Public bypasses the user JWT guard only; the dedicated credential guard is mandatory. */ +@Public() +@UseGuards(EverAsyncConnectorGuard) +@ApiTags('Ever Async Connector') +@ApiHeader({ name: 'X-INTEGRATION-ID', required: true }) +@ApiHeader({ name: 'X-APP-ID', required: true }) +@ApiHeader({ name: 'X-API-KEY', required: true }) +@Controller('/integration/ever-async/connector') +export class EverAsyncConnectorController { + constructor(private readonly service: EverAsyncIntegrationService) {} + + @Get('/status') + @Header('Cache-Control', 'no-store') + status(@Req() request: ConnectorRequest) { + const { integrationTenantId, tenantId, organizationId } = request.everAsyncScope; + return { integrationTenantId, tenantId, organizationId, isEnabled: true }; + } + + @Get('/tasks') + @Header('Cache-Control', 'no-store') + tasks( + @Req() request: ConnectorRequest, + @Query('chatUserId') chatUserId?: string, + @Query('taskId') taskId?: string, + @Query('channel') channel?: string, + @Query('workspace') workspace?: string + ) { + return this.service.getConnectorTasks(request.everAsyncScope, { chatUserId, taskId, channel, workspace }); + } +} diff --git a/packages/plugins/integration-ever-async/src/lib/ever-async-integration.service.ts b/packages/plugins/integration-ever-async/src/lib/ever-async-integration.service.ts index 024e9c15e3..dcd9413628 100644 --- a/packages/plugins/integration-ever-async/src/lib/ever-async-integration.service.ts +++ b/packages/plugins/integration-ever-async/src/lib/ever-async-integration.service.ts @@ -1,347 +1,467 @@ -import { HttpException, HttpStatus, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadGatewayException, + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, + OnApplicationBootstrap +} from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'crypto'; +import { DataSource, EntityManager, In } from 'typeorm'; import { firstValueFrom } from 'rxjs'; -import { ID, IntegrationEnum, IIntegrationSetting, IIntegrationTenant } from '@gauzy/contracts'; -import { IntegrationService, IntegrationTenantService, RequestContext } from '@gauzy/core'; -import { EverAsyncSettingName, EVER_ASYNC_INTEGRATION_NAME } from './ever-async-setting.enum'; -import { ConfigureEverAsyncIntegrationDto } from './dto/configure-ever-async-integration.dto'; -import { UpdateEverAsyncSettingsDto } from './dto/update-ever-async-settings.dto'; -import { EverAsyncUserMappingDto } from './dto/ever-async-user-mapping.dto'; +import { isUUID } from 'class-validator'; +import { ID, IntegrationEnum, IntegrationTypeEnum } from '@gauzy/contracts'; +import { + createSsrfSafeHttpsAgent, + Employee, + Integration, + IntegrationType, + IntegrationSetting, + IntegrationTenant, + OrganizationProject, + RequestContext, + Task, + UserOrganization +} from '@gauzy/core'; +import { getUnsafeOutboundUrlReason } from '@gauzy/utils'; +import { ConfigureEverAsyncIntegrationDto, UpdateEverAsyncSettingsDto } from './dto'; +import { EverAsyncSettingName as Setting } from './ever-async-setting.enum'; -/** Timeout (ms) for the /healthz connectivity check against the Ever Async server. */ -const HEALTHZ_TIMEOUT_MS = 5000; +export interface EverAsyncConnectorScope { + integrationTenantId: ID; + tenantId: ID; + organizationId: ID; + projectIds: ID[]; + userMappings: { channel: string; workspace: string; chatUserId: string; employeeId: ID }[]; +} +/** Organization-scoped management and a separate, read-only connector boundary. */ @Injectable() -export class EverAsyncIntegrationService { - private readonly logger = new Logger(EverAsyncIntegrationService.name); +export class EverAsyncIntegrationService implements OnApplicationBootstrap { + private readonly httpsAgent = createSsrfSafeHttpsAgent(); constructor( - private readonly integrationService: IntegrationService, - private readonly integrationTenantService: IntegrationTenantService, + private readonly dataSource: DataSource, private readonly httpService: HttpService ) {} - /** - * Configure the Ever Async integration for the current tenant. - * Finds or creates the base Integration record and stores the server URL, - * API token (write-only) and user mappings as integration settings. - * - * @param dto - Server URL, API token and optional user mappings - * @param organizationId - Optional organization scope - * @returns The created integration tenant ID - */ - async setupIntegration( - dto: ConfigureEverAsyncIntegrationDto, - organizationId?: string - ): Promise<{ integrationTenantId: ID }> { - const tenantId = RequestContext.currentTenantId() ?? undefined; - organizationId = organizationId ?? RequestContext.currentOrganizationId() ?? undefined; + async onApplicationBootstrap() { + await this.ensureCatalog(); + } - if (!tenantId) { - throw new HttpException( - 'Tenant context is required to configure an Ever Async integration.', - HttpStatus.BAD_REQUEST - ); + /** Make the plugin discoverable on existing installations as well as fresh seeds. */ + private async ensureCatalog() { + return this.dataSource.transaction(async (manager) => { + if (manager.connection.options.type === 'postgres') + await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', ['ever-async:catalog']); + let integration = await manager.findOne(Integration, { + where: { provider: IntegrationEnum.EVER_ASYNC }, + relations: { integrationTypes: true } + }); + const types = await manager.find(IntegrationType, { + where: { name: In([IntegrationTypeEnum.ALL_INTEGRATIONS, IntegrationTypeEnum.PROJECT_MANAGEMENT]) } + }); + if (!integration) + integration = manager.create(Integration, { + id: randomUUID(), + name: IntegrationEnum.EVER_ASYNC, + provider: IntegrationEnum.EVER_ASYNC, + imgSrc: 'integrations/ever-async.svg', + redirectUrl: 'ever-async', + isComingSoon: false, + isPaid: false, + order: 12, + integrationTypes: types + }); + else { + const missing = types.filter( + (type) => !integration!.integrationTypes?.some((current) => current.id === type.id) + ); + if (!missing.length) return integration; + integration.integrationTypes = [...(integration.integrationTypes ?? []), ...missing]; + } + return manager.save(Integration, integration); + }); + } + + private async scope(organizationId?: ID) { + const tenantId = RequestContext.currentTenantId(); + const userId = RequestContext.currentUserId(); + organizationId ??= RequestContext.currentOrganizationId() ?? undefined; + if (!tenantId || !userId || !organizationId || !isUUID(organizationId)) { + throw new ForbiddenException('An authenticated organization context is required.'); } - - // Check if an Ever Async integration already exists for this tenant - const existing = await this.findIntegrationTenant(tenantId); - if (existing) { - throw new HttpException( - 'Ever Async integration is already configured for this tenant. Use the update endpoint to modify settings.', - HttpStatus.CONFLICT - ); - } - - // Find or create the base Integration record - const integration = await this.findOrCreateBaseIntegration(); - - // Build the integration settings array - const settings: Partial[] = [ - { settingsName: EverAsyncSettingName.EVER_ASYNC_SERVER_URL, settingsValue: dto.serverUrl }, - { settingsName: EverAsyncSettingName.EVER_ASYNC_API_TOKEN, settingsValue: dto.apiToken }, - { - settingsName: EverAsyncSettingName.EVER_ASYNC_USER_MAPPINGS, - settingsValue: JSON.stringify(dto.userMappings ?? []) - }, - { settingsName: EverAsyncSettingName.IS_ENABLED, settingsValue: 'true' } - ]; - - // Create the IntegrationTenant record with cascaded settings - const integrationTenant = await this.integrationTenantService.create({ - // scaffold: replace with `IntegrationEnum.EVER_ASYNC` once the enum - // member is added to @gauzy/contracts (see ever-async-setting.enum.ts) - name: EVER_ASYNC_INTEGRATION_NAME as IntegrationEnum, - integration: integration ?? undefined, + const membership = await this.dataSource.getRepository(UserOrganization).findOneBy({ tenantId, organizationId, - settings: settings as IIntegrationSetting[] + userId, + isActive: true, + isArchived: false }); - - this.logger.log(`Ever Async integration configured for tenant ${tenantId}`); - - return { integrationTenantId: integrationTenant.id! }; + if (!membership) throw new ForbiddenException('Access to this organization is required.'); + return { tenantId, organizationId }; } - /** - * Retrieve the current Ever Async integration settings for the tenant. - * The API token is NOT returned (write-only credential) — only a - * `hasApiToken` flag. - */ - async getSettings(_organizationId?: string): Promise<{ - integrationTenantId: ID; - serverUrl: string; - userMappings: EverAsyncUserMappingDto[]; - isEnabled: boolean; - hasApiToken: boolean; - }> { - const tenantId = RequestContext.currentTenantId() ?? undefined; - const integrationTenant = await this.findIntegrationTenantOrFail(tenantId); - - const settingsMap = this.buildSettingsMap(integrationTenant.settings || []); - - return { - integrationTenantId: integrationTenant.id!, - serverUrl: settingsMap[EverAsyncSettingName.EVER_ASYNC_SERVER_URL] || '', - userMappings: this.parseUserMappings(settingsMap[EverAsyncSettingName.EVER_ASYNC_USER_MAPPINGS]), - isEnabled: settingsMap[EverAsyncSettingName.IS_ENABLED] === 'true', - hasApiToken: !!settingsMap[EverAsyncSettingName.EVER_ASYNC_API_TOKEN] - }; - } - - /** - * Update Ever Async settings for the current tenant. - * Only the fields supplied in the DTO are patched; `apiToken` replaces the - * stored token when provided. - */ - async updateSettings( - dto: UpdateEverAsyncSettingsDto, - organizationId?: string - ): Promise<{ integrationTenantId: ID; updated: boolean }> { - const tenantId = RequestContext.currentTenantId() ?? undefined; - const integrationTenant = await this.findIntegrationTenantOrFail(tenantId); - - const existingSettings = integrationTenant.settings || []; - - // Build a map of setting name → setting object for easy update - const settingsIndex = new Map(); - for (const s of existingSettings) { - settingsIndex.set(s.settingsName, s); - } - - const updates: Array<[string, string | undefined]> = [ - [EverAsyncSettingName.EVER_ASYNC_SERVER_URL, dto.serverUrl], - [EverAsyncSettingName.EVER_ASYNC_API_TOKEN, dto.apiToken], - [ - EverAsyncSettingName.EVER_ASYNC_USER_MAPPINGS, - dto.userMappings !== undefined ? JSON.stringify(dto.userMappings) : undefined - ] - ]; - - for (const [name, value] of updates) { - if (value !== undefined) { - const existing = settingsIndex.get(name); - if (existing) { - existing.settingsValue = value; - } else { - existingSettings.push({ - settingsName: name, - settingsValue: value, - tenantId, - organizationId - } as IIntegrationSetting); - } - } - } - - integrationTenant.settings = existingSettings; - await this.integrationTenantService.save(integrationTenant); - - this.logger.log(`Ever Async integration settings updated for tenant ${tenantId}`); - - return { - integrationTenantId: integrationTenant.id!, - updated: true - }; - } - - /** - * Verify connectivity to the Ever Async server by pinging `GET {serverUrl}/healthz`. - * Uses the provided URL (connect wizard) or falls back to the stored setting. - */ - async verifyConnection(serverUrl?: string): Promise<{ ok: boolean; serverUrl: string }> { - let targetUrl = serverUrl; - - if (!targetUrl) { - const tenantId = RequestContext.currentTenantId() ?? undefined; - const integrationTenant = await this.findIntegrationTenantOrFail(tenantId); - const settingsMap = this.buildSettingsMap(integrationTenant.settings || []); - targetUrl = settingsMap[EverAsyncSettingName.EVER_ASYNC_SERVER_URL]; - } - - if (!targetUrl) { - throw new HttpException('No Ever Async server URL to verify.', HttpStatus.BAD_REQUEST); - } - - const healthzUrl = `${targetUrl.replace(/\/+$/, '')}/healthz`; - - try { - // The Ever Async server exposes an unauthenticated /healthz endpoint - // (see ever-co/ever-async docs/integrations/gauzy.md). - await firstValueFrom(this.httpService.get(healthzUrl, { timeout: HEALTHZ_TIMEOUT_MS })); - return { ok: true, serverUrl: targetUrl }; - } catch (error: unknown) { - this.logger.warn( - `Ever Async healthz check failed for ${healthzUrl}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - throw new HttpException( - `Ever Async server is not reachable at ${targetUrl}.`, - HttpStatus.BAD_GATEWAY - ); - } - } - - /** - * Check if the Ever Async integration is enabled for the current tenant. - */ - async getStatus(): Promise<{ isEnabled: boolean; integrationTenantId: ID | null }> { - const tenantId = RequestContext.currentTenantId() ?? undefined; - const integrationTenant = tenantId ? await this.findIntegrationTenant(tenantId) : null; - - if (!integrationTenant) { - return { isEnabled: false, integrationTenantId: null }; - } - - const settingsMap = this.buildSettingsMap(integrationTenant.settings || []); - - return { - isEnabled: !!integrationTenant.isActive && settingsMap[EverAsyncSettingName.IS_ENABLED] === 'true', - integrationTenantId: integrationTenant.id ?? null - }; - } - - /** - * Remove/archive the Ever Async integration for the tenant (soft delete). - */ - async removeIntegration(integrationTenantId: ID): Promise<{ success: boolean }> { - const tenantId = RequestContext.currentTenantId() ?? undefined; - const integrationTenant = await this.findIntegrationTenantOrFail(tenantId); - - if (integrationTenant.id !== integrationTenantId) { - throw new HttpException('Integration tenant ID mismatch.', HttpStatus.BAD_REQUEST); - } - - // Soft-delete by marking as archived and inactive - integrationTenant.isActive = false; - integrationTenant.isArchived = true; - - // Disable the integration in settings - const settings = integrationTenant.settings || []; - const enabledSetting = settings.find((s) => s.settingsName === EverAsyncSettingName.IS_ENABLED); - if (enabledSetting) { - enabledSetting.settingsValue = 'false'; - } - - await this.integrationTenantService.save(integrationTenant); - - this.logger.log(`Ever Async integration removed for tenant ${tenantId}`); - - return { success: true }; - } - - /** - * Find the Ever Async IntegrationTenant for the given tenant ID. - */ - private async findIntegrationTenant(tenantId: ID | undefined): Promise { - if (!tenantId) { - return null; - } - - try { - return await this.integrationTenantService.findOneByOptions({ - where: { - tenantId, - name: EVER_ASYNC_INTEGRATION_NAME as IntegrationEnum, - isActive: true, - isArchived: false - }, - relations: ['settings'] - }); - } catch (error: unknown) { - if (error instanceof NotFoundException) { - return null; - } - throw error; - } - } - - /** - * Find the Ever Async IntegrationTenant or throw a 404. - */ - private async findIntegrationTenantOrFail(tenantId: ID | undefined): Promise { - const integrationTenant = await this.findIntegrationTenant(tenantId); - if (!integrationTenant) { - throw new HttpException('Ever Async integration is not configured for this tenant.', HttpStatus.NOT_FOUND); - } - return integrationTenant; - } - - /** - * Find or create the base Integration record for Ever Async. - */ - private async findOrCreateBaseIntegration() { - try { - const existing = await this.integrationService.findOneByOptions({ - where: { provider: 'Ever_Async' } - }); - if (existing) { - return existing; - } - } catch (error: unknown) { - if (!(error instanceof NotFoundException)) { - throw error; - } - } - - return await this.integrationService.create({ - name: 'Ever Async', - provider: 'Ever_Async', - // scaffold: add the `integrations/ever-async.svg` icon asset during - // wiring (same location as `integrations/plane.svg`) - imgSrc: 'integrations/ever-async.svg', - isComingSoon: false, - isPaid: false, - redirectUrl: 'ever-async', - order: 12 + private async find(scope: { tenantId: ID; organizationId: ID }, manager = this.dataSource.manager) { + return manager.findOne(IntegrationTenant, { + where: { ...scope, name: IntegrationEnum.EVER_ASYNC, isActive: true, isArchived: false }, + relations: { settings: true } }); } - /** - * Build a key-value map from an array of IntegrationSettings. - */ - private buildSettingsMap(settings: IIntegrationSetting[]): Record { - const map: Record = {}; - for (const s of settings) { - map[s.settingsName] = s.settingsValue; - } - return map; + private async requireIntegration(organizationId?: ID) { + const scope = await this.scope(organizationId); + const integration = await this.find(scope); + if (!integration?.id || !integration.tenantId || !integration.organizationId) + throw new NotFoundException('Ever Async is not configured for this organization.'); + return integration as IntegrationTenant & { id: ID; tenantId: ID; organizationId: ID }; } - /** - * Parse the JSON-serialized user mappings setting, tolerating bad data. - */ - private parseUserMappings(raw: string | undefined): EverAsyncUserMappingDto[] { - if (!raw) { - return []; - } + private settings(integration: IntegrationTenant): Record { + return Object.fromEntries((integration.settings ?? []).map((s) => [s.settingsName, s.settingsValue])); + } + + private parseArray(raw?: string): T[] { + if (!raw) return []; try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; + const value: unknown = JSON.parse(raw); + return Array.isArray(value) ? (value as T[]) : []; } catch { - this.logger.warn('Failed to parse stored Ever Async user mappings; returning empty list.'); return []; } } + + private set(integration: IntegrationTenant, values: Record, manager: EntityManager) { + integration.settings ??= []; + for (const [settingsName, settingsValue] of Object.entries(values)) { + const existing = integration.settings.find((s) => s.settingsName === settingsName); + if (existing) existing.settingsValue = settingsValue; + else + integration.settings.push( + manager.create(IntegrationSetting, { + id: randomUUID(), + tenantId: integration.tenantId, + organizationId: integration.organizationId, + settingsName, + settingsValue + }) + ); + } + } + + private credentials() { + const apiKey = randomBytes(16).toString('hex'); + const apiSecret = randomBytes(32).toString('hex'); + return { apiKey, apiSecret, digest: createHash('sha256').update(apiSecret).digest('hex') }; + } + + private serverUrl(input: string) { + const reason = getUnsafeOutboundUrlReason(input); + if (reason) throw new BadRequestException(`Invalid Ever Async URL: ${reason}`); + const url = new URL(input); + if (url.search || url.hash) + throw new BadRequestException('Ever Async URL must not contain a query or fragment.'); + return url.toString().replace(/\/+$/, ''); + } + + private async validateSelection(dto: UpdateEverAsyncSettingsDto, scope: { tenantId: ID; organizationId: ID }) { + if (dto.userMappings !== undefined) { + const mappings = dto.userMappings; + if ( + new Set(mappings.map((m) => JSON.stringify([m.channel, m.workspace, m.chatUserId]))).size !== + mappings.length + ) { + throw new BadRequestException( + 'Each chat user can have only one employee mapping per channel and workspace.' + ); + } + const ids = [...new Set(mappings.map((m) => m.employeeId))]; + if ( + ids.length && + (await this.dataSource + .getRepository(Employee) + .countBy({ ...scope, id: In(ids), isActive: true, isArchived: false })) !== ids.length + ) { + throw new BadRequestException('Every mapped employee must be active in this organization.'); + } + } + if (dto.projectIds !== undefined) { + const ids = [...new Set(dto.projectIds)]; + if ( + ids.length && + (await this.dataSource + .getRepository(OrganizationProject) + .countBy({ ...scope, id: In(ids), isActive: true, isArchived: false })) !== ids.length + ) { + throw new BadRequestException('Every selected project must be active in this organization.'); + } + } + } + + async setupIntegration(dto: ConfigureEverAsyncIntegrationDto, organizationId?: ID) { + const scope = await this.scope(organizationId); + const serverUrl = this.serverUrl(dto.serverUrl); + await this.validateSelection(dto, scope); + const base = await this.ensureCatalog(); + return this.dataSource.transaction(async (manager) => { + // Serialize setup across administrators of the same organization on PostgreSQL. + if (manager.connection.options.type === 'postgres') { + await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [ + `ever-async:${scope.tenantId}:${scope.organizationId}` + ]); + } + if (await this.find(scope, manager)) + throw new ConflictException('Ever Async is already configured for this organization.'); + const integration = manager.create(IntegrationTenant, { + id: randomUUID(), + ...scope, + name: IntegrationEnum.EVER_ASYNC, + integrationId: base.id, + isActive: true, + isArchived: false + }); + const key = this.credentials(); + this.set( + integration, + { + [Setting.EVER_ASYNC_SERVER_URL]: serverUrl, + [Setting.EVER_ASYNC_USER_MAPPINGS]: JSON.stringify(dto.userMappings ?? []), + [Setting.EVER_ASYNC_PROJECT_IDS]: JSON.stringify(dto.projectIds ?? []), + [Setting.EVER_ASYNC_KEY_ID]: key.apiKey, + [Setting.EVER_ASYNC_SECRET_HASH]: key.digest, + [Setting.IS_ENABLED]: 'true' + }, + manager + ); + await manager.save(IntegrationTenant, integration); + return { integrationTenantId: integration.id, ...scope, apiKey: key.apiKey, apiSecret: key.apiSecret }; + }); + } + + async getSettings(organizationId?: ID) { + const integration = await this.requireIntegration(organizationId); + const settings = this.settings(integration); + return { + integrationTenantId: integration.id, + tenantId: integration.tenantId, + organizationId: integration.organizationId, + serverUrl: settings[Setting.EVER_ASYNC_SERVER_URL] ?? '', + userMappings: this.parseArray<{ channel: string; workspace: string; chatUserId: string; employeeId: ID }>( + settings[Setting.EVER_ASYNC_USER_MAPPINGS] + ), + projectIds: this.parseArray(settings[Setting.EVER_ASYNC_PROJECT_IDS]), + isEnabled: settings[Setting.IS_ENABLED] === 'true', + hasApiKey: !!settings[Setting.EVER_ASYNC_SECRET_HASH] + }; + } + + private async mutate( + organizationId: ID | undefined, + change: ( + integration: IntegrationTenant & { id: ID; tenantId: ID; organizationId: ID }, + manager: EntityManager + ) => Promise + ) { + const scope = await this.scope(organizationId); + return this.dataSource.transaction(async (manager) => { + if (['postgres', 'mysql', 'mariadb'].includes(manager.connection.options.type)) { + await manager.findOne(IntegrationTenant, { + where: { ...scope, name: IntegrationEnum.EVER_ASYNC, isActive: true, isArchived: false }, + lock: { mode: 'pessimistic_write' } + }); + } + const integration = await this.find(scope, manager); + if (!integration?.id || !integration.tenantId || !integration.organizationId) + throw new NotFoundException('Ever Async is not configured for this organization.'); + return change(integration as IntegrationTenant & { id: ID; tenantId: ID; organizationId: ID }, manager); + }); + } + + async updateSettings(dto: UpdateEverAsyncSettingsDto, organizationId?: ID) { + return this.mutate(organizationId, async (integration, manager) => { + await this.validateSelection(dto, { + tenantId: integration.tenantId, + organizationId: integration.organizationId + }); + const values: Record = {}; + if (dto.serverUrl !== undefined) values[Setting.EVER_ASYNC_SERVER_URL] = this.serverUrl(dto.serverUrl); + if (dto.userMappings !== undefined) + values[Setting.EVER_ASYNC_USER_MAPPINGS] = JSON.stringify(dto.userMappings); + if (dto.projectIds !== undefined) + values[Setting.EVER_ASYNC_PROJECT_IDS] = JSON.stringify([...new Set(dto.projectIds)]); + if (dto.isEnabled !== undefined) values[Setting.IS_ENABLED] = String(dto.isEnabled); + this.set(integration, values, manager); + await manager.save(IntegrationTenant, integration); + return { integrationTenantId: integration.id, updated: true }; + }); + } + + async rotateCredentials(organizationId?: ID) { + return this.mutate(organizationId, async (integration, manager) => { + const key = this.credentials(); + this.set( + integration, + { [Setting.EVER_ASYNC_KEY_ID]: key.apiKey, [Setting.EVER_ASYNC_SECRET_HASH]: key.digest }, + manager + ); + await manager.save(IntegrationTenant, integration); + return { + integrationTenantId: integration.id, + tenantId: integration.tenantId, + organizationId: integration.organizationId, + apiKey: key.apiKey, + apiSecret: key.apiSecret + }; + }); + } + + async getStatus(organizationId?: ID) { + const integration = await this.find(await this.scope(organizationId)); + return { + isEnabled: !!integration && this.settings(integration)[Setting.IS_ENABLED] === 'true', + integrationTenantId: integration?.id ?? null + }; + } + + async removeIntegration(integrationTenantId: ID, organizationId?: ID) { + return this.mutate(organizationId, async (integration, manager) => { + if (integration.id !== integrationTenantId) + throw new NotFoundException('Ever Async integration not found.'); + integration.isActive = false; + integration.isArchived = true; + this.set(integration, { [Setting.IS_ENABLED]: 'false' }, manager); + await manager.save(IntegrationTenant, integration); + return { success: true }; + }); + } + + async verifyConnection(serverUrl: string) { + const target = this.serverUrl(serverUrl); + try { + const response = await firstValueFrom( + this.httpService.get(`${target}/healthz`, { + timeout: 5000, + maxRedirects: 0, + maxContentLength: 1024, + proxy: false, + httpsAgent: this.httpsAgent + }) + ); + if (typeof response.data !== 'string' || response.data.trim() !== 'ok') + throw new Error('Unexpected health response'); + return { ok: true, serverUrl: target }; + } catch { + throw new BadGatewayException('The Ever Async server did not return a valid health response.'); + } + } + + async getOptions(organizationId?: ID) { + const scope = await this.scope(organizationId); + const where = { ...scope, isActive: true, isArchived: false }; + const [employees, projects] = await Promise.all([ + this.dataSource.getRepository(Employee).find({ where, relations: { user: true } }), + this.dataSource.getRepository(OrganizationProject).find({ where, order: { name: 'ASC' } }) + ]); + return { + employees: employees.map((employee) => ({ + id: employee.id, + name: [employee.user?.firstName, employee.user?.lastName].filter(Boolean).join(' ') || employee.id + })), + projects: projects.map((project) => ({ id: project.id, name: project.name })) + }; + } + + async authenticateConnector( + integrationTenantId: string, + apiKey: string, + apiSecret: string + ): Promise { + if (!isUUID(integrationTenantId) || !apiKey || !apiSecret || apiSecret.length > 256) + throw new UnauthorizedException('Invalid connector credentials.'); + const integration = await this.dataSource.getRepository(IntegrationTenant).findOne({ + where: { id: integrationTenantId, name: IntegrationEnum.EVER_ASYNC, isActive: true, isArchived: false }, + relations: { settings: true } + }); + const settings = integration ? this.settings(integration) : {}; + const stored = settings[Setting.EVER_ASYNC_SECRET_HASH] ?? ''; + const digest = createHash('sha256').update(apiSecret).digest('hex'); + if ( + !integration?.id || + !integration.tenantId || + !integration.organizationId || + settings[Setting.EVER_ASYNC_KEY_ID] !== apiKey || + stored.length !== digest.length || + !timingSafeEqual(Buffer.from(stored), Buffer.from(digest)) + ) { + throw new UnauthorizedException('Invalid connector credentials.'); + } + if (settings[Setting.IS_ENABLED] !== 'true') + throw new ForbiddenException('Ever Async integration is disabled.'); + return { + integrationTenantId: integration.id, + tenantId: integration.tenantId, + organizationId: integration.organizationId, + projectIds: this.parseArray(settings[Setting.EVER_ASYNC_PROJECT_IDS]), + userMappings: this.parseArray<{ channel: string; workspace: string; chatUserId: string; employeeId: ID }>( + settings[Setting.EVER_ASYNC_USER_MAPPINGS] + ) + }; + } + + async getConnectorTasks( + scope: EverAsyncConnectorScope, + query: { channel?: string; workspace?: string; chatUserId?: string; taskId?: ID } + ) { + if (!!query.chatUserId === !!query.taskId) + throw new BadRequestException('Supply exactly one chatUserId or taskId.'); + if (query.taskId && !isUUID(query.taskId)) throw new BadRequestException('Invalid task ID.'); + if ( + query.chatUserId && + (typeof query.chatUserId !== 'string' || + query.chatUserId.length > 200 || + !['slack', 'discord'].includes(query.channel ?? '') || + typeof query.workspace !== 'string' || + !/^[^\s]{1,200}$/.test(query.workspace)) + ) + throw new BadRequestException('Invalid chat user ID.'); + const empty = { items: [], total: 0 }; + if (!scope.projectIds.length) return empty; + const employeeId = query.chatUserId + ? scope.userMappings.find( + (m) => + m.channel === query.channel && + m.workspace === query.workspace && + m.chatUserId === query.chatUserId + )?.employeeId + : undefined; + if (query.chatUserId && !employeeId) return empty; + const active = { + tenantId: scope.tenantId, + organizationId: scope.organizationId, + isActive: true, + isArchived: false + }; + const tasks = await this.dataSource.getRepository(Task).find({ + where: { + ...active, + ...(query.taskId ? { id: query.taskId } : {}), + project: { ...active, id: In(scope.projectIds) }, + ...(employeeId ? { members: { ...active, id: employeeId } } : {}) + }, + order: { updatedAt: 'DESC', id: 'DESC' }, + take: 10, + select: { id: true, title: true, status: true, number: true, projectId: true, updatedAt: true } + }); + const items = tasks.map((task) => ({ + id: task.id, + title: task.title, + status: task.status ?? null, + taskNumber: task.number ?? null, + projectId: task.projectId + })); + return { items, total: items.length }; + } } diff --git a/packages/plugins/integration-ever-async/src/lib/ever-async-setting.enum.ts b/packages/plugins/integration-ever-async/src/lib/ever-async-setting.enum.ts index f4fb32f54a..9bca5141e7 100644 --- a/packages/plugins/integration-ever-async/src/lib/ever-async-setting.enum.ts +++ b/packages/plugins/integration-ever-async/src/lib/ever-async-setting.enum.ts @@ -1,33 +1,15 @@ -/** - * Setting name constants for the Ever Async integration. - * These are stored as `settingsName` in the `integration_setting` table. - */ +import { IntegrationEnum } from '@gauzy/contracts'; + +/** Persisted names are stable; connector secrets are stored only as SHA-256 digests. */ export enum EverAsyncSettingName { - /** Base URL of the tenant's Ever Async server (e.g. https://async.example.com) */ EVER_ASYNC_SERVER_URL = 'EVER_ASYNC_SERVER_URL', - - /** - * API token used to authenticate against the Ever Async server. - * WRITE-ONLY: accepted on setup/update, never returned by any read endpoint. - */ + /** Legacy scaffold setting; never used as a connector credential. */ EVER_ASYNC_API_TOKEN = 'EVER_ASYNC_API_TOKEN', - - /** - * JSON-serialized array of chat-user → employee mappings: - * `[{ "chatUserId": "U0123ABC", "employeeId": "" }, ...]`. - * Mirrors `[connectors.gauzy.user_map]` in the Ever Async `everasync.toml`. - */ EVER_ASYNC_USER_MAPPINGS = 'EVER_ASYNC_USER_MAPPINGS', - - /** Whether the integration is enabled */ + EVER_ASYNC_PROJECT_IDS = 'EVER_ASYNC_PROJECT_IDS', + EVER_ASYNC_KEY_ID = 'EVER_ASYNC_KEY_ID', + EVER_ASYNC_SECRET_HASH = 'EVER_ASYNC_SECRET_HASH', IS_ENABLED = 'IS_ENABLED' } -/** - * Integration name/provider used for the `IntegrationTenant` record. - * - * scaffold: add `EVER_ASYNC = 'Ever_Async'` to `IntegrationEnum` in - * `packages/contracts/src/lib/integration.model.ts` during wiring, then replace - * this constant with `IntegrationEnum.EVER_ASYNC` (mirrors `IntegrationEnum.PLANE`). - */ -export const EVER_ASYNC_INTEGRATION_NAME = 'Ever_Async'; +export const EVER_ASYNC_INTEGRATION_NAME = IntegrationEnum.EVER_ASYNC; diff --git a/packages/plugins/integration-ever-async/src/lib/ever-async.controller.ts b/packages/plugins/integration-ever-async/src/lib/ever-async.controller.ts index 7cae58740e..0802b42463 100644 --- a/packages/plugins/integration-ever-async/src/lib/ever-async.controller.ts +++ b/packages/plugins/integration-ever-async/src/lib/ever-async.controller.ts @@ -9,9 +9,10 @@ import { Query, HttpCode, HttpStatus, - UseGuards + UseGuards, + Header } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ID, PermissionsEnum } from '@gauzy/contracts'; import { Permissions, @@ -21,103 +22,73 @@ import { TenantPermissionGuard } from '@gauzy/core'; import { EverAsyncIntegrationService } from './ever-async-integration.service'; -import { ConfigureEverAsyncIntegrationDto } from './dto/configure-ever-async-integration.dto'; -import { UpdateEverAsyncSettingsDto } from './dto/update-ever-async-settings.dto'; -import { VerifyEverAsyncConnectionDto } from './dto/verify-ever-async-connection.dto'; +import { ConfigureEverAsyncIntegrationDto, UpdateEverAsyncSettingsDto, VerifyEverAsyncConnectionDto } from './dto'; @ApiTags('Ever Async Integration') @ApiBearerAuth() @UseGuards(TenantPermissionGuard, PermissionGuard) @Controller('/integration/ever-async') export class EverAsyncController { - constructor(private readonly everAsyncIntegrationService: EverAsyncIntegrationService) {} + constructor(private readonly service: EverAsyncIntegrationService) {} - /** - * Configure the Ever Async integration for the current tenant: - * stores the Ever Async server URL, the API token (write-only) and the - * initial chat-user → employee mappings. - */ @Post('/setup') @Permissions(PermissionsEnum.INTEGRATION_ADD) @UseValidationPipe() - @ApiOperation({ summary: 'Configure Ever Async integration for the current tenant.' }) - @ApiResponse({ status: 201, description: 'Ever Async integration configured successfully.' }) - @ApiResponse({ status: 409, description: 'Ever Async integration already configured.' }) - async setupIntegration( - @Body() dto: ConfigureEverAsyncIntegrationDto, - @Query('organizationId') organizationId?: string - ): Promise<{ integrationTenantId: ID }> { - return await this.everAsyncIntegrationService.setupIntegration(dto, organizationId); + @Header('Cache-Control', 'no-store') + @ApiOperation({ summary: 'Connect Ever Async and return its read-only credential once.' }) + setupIntegration(@Body() dto: ConfigureEverAsyncIntegrationDto, @Query('organizationId') organizationId?: ID) { + return this.service.setupIntegration(dto, organizationId); } - /** - * Get current Ever Async integration settings for the tenant. - * Does NOT return the API token (write-only credential). - */ @Get('/settings') @Permissions(PermissionsEnum.INTEGRATION_VIEW) - @ApiOperation({ summary: 'Get Ever Async integration settings for the current tenant.' }) - @ApiResponse({ status: 200, description: 'Settings retrieved successfully.' }) - @ApiResponse({ status: 404, description: 'Ever Async integration not configured.' }) - async getSettings(@Query('organizationId') organizationId?: string) { - return await this.everAsyncIntegrationService.getSettings(organizationId); + getSettings(@Query('organizationId') organizationId?: ID) { + return this.service.getSettings(organizationId); + } + + @Get('/options') + @Permissions(PermissionsEnum.INTEGRATION_VIEW) + getOptions(@Query('organizationId') organizationId?: ID) { + return this.service.getOptions(organizationId); } - /** - * Update Ever Async integration settings (server URL, token, user mappings). - */ @Put('/settings') @Permissions(PermissionsEnum.INTEGRATION_EDIT) @UseValidationPipe() - @ApiOperation({ summary: 'Update Ever Async integration settings.' }) - @ApiResponse({ status: 200, description: 'Settings updated successfully.' }) - @ApiResponse({ status: 404, description: 'Ever Async integration not configured.' }) - async updateSettings( - @Body() dto: UpdateEverAsyncSettingsDto, - @Query('organizationId') organizationId?: string - ): Promise<{ integrationTenantId: ID; updated: boolean }> { - return await this.everAsyncIntegrationService.updateSettings(dto, organizationId); + updateSettings(@Body() dto: UpdateEverAsyncSettingsDto, @Query('organizationId') organizationId?: ID) { + return this.service.updateSettings(dto, organizationId); + } + + @Post('/credentials/rotate') + @Permissions(PermissionsEnum.INTEGRATION_EDIT) + @HttpCode(HttpStatus.OK) + @Header('Cache-Control', 'no-store') + rotateCredentials(@Query('organizationId') organizationId?: ID) { + return this.service.rotateCredentials(organizationId); } - /** - * Verify connectivity to an Ever Async server by pinging its `/healthz` - * endpoint. Accepts an explicit `serverUrl` (connect wizard, before saving) - * or falls back to the stored setting. - */ @Post('/verify') @Permissions(PermissionsEnum.INTEGRATION_VIEW) @HttpCode(HttpStatus.OK) @UseValidationPipe() - @ApiOperation({ summary: 'Verify connectivity to the Ever Async server (/healthz ping).' }) - @ApiResponse({ status: 200, description: 'Ever Async server is reachable.' }) - @ApiResponse({ status: 502, description: 'Ever Async server is not reachable.' }) - async verifyConnection(@Body() dto: VerifyEverAsyncConnectionDto): Promise<{ ok: boolean; serverUrl: string }> { - return await this.everAsyncIntegrationService.verifyConnection(dto.serverUrl); + @ApiOperation({ summary: 'Check server reachability. This does not install or authenticate a chat integration.' }) + verifyConnection(@Body() dto: VerifyEverAsyncConnectionDto) { + return this.service.verifyConnection(dto.serverUrl); } - /** - * Check if the Ever Async integration is enabled for the current tenant. - */ @Get('/status') @Permissions(PermissionsEnum.INTEGRATION_VIEW) - @ApiOperation({ summary: 'Check Ever Async integration status for the current tenant.' }) - @ApiResponse({ status: 200, description: 'Status retrieved successfully.' }) - async getStatus(): Promise<{ isEnabled: boolean; integrationTenantId: ID | null }> { - return await this.everAsyncIntegrationService.getStatus(); + getStatus(@Query('organizationId') organizationId?: ID) { + return this.service.getStatus(organizationId); } - /** - * Remove/archive the Ever Async integration for the tenant. - */ @Delete('/:integrationTenantId') @Permissions(PermissionsEnum.INTEGRATION_DELETE) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Remove Ever Async integration for the current tenant.' }) - @ApiResponse({ status: 200, description: 'Integration removed successfully.' }) - @ApiResponse({ status: 404, description: 'Ever Async integration not configured.' }) - async removeIntegration( - @Param('integrationTenantId', UUIDValidationPipe) integrationTenantId: ID - ): Promise<{ success: boolean }> { - return await this.everAsyncIntegrationService.removeIntegration(integrationTenantId); + removeIntegration( + @Param('integrationTenantId', UUIDValidationPipe) integrationTenantId: ID, + @Query('organizationId') organizationId?: ID + ) { + return this.service.removeIntegration(integrationTenantId, organizationId); } } diff --git a/packages/plugins/integration-ever-async/src/lib/ever-async.module.ts b/packages/plugins/integration-ever-async/src/lib/ever-async.module.ts index f0b7bcb50e..ba5ee07c58 100644 --- a/packages/plugins/integration-ever-async/src/lib/ever-async.module.ts +++ b/packages/plugins/integration-ever-async/src/lib/ever-async.module.ts @@ -8,6 +8,7 @@ import { PluginCommonModule, RolePermissionModule } from '@gauzy/core'; +import { EverAsyncConnectorController, EverAsyncConnectorGuard } from './ever-async-connector.controller'; import { EverAsyncController } from './ever-async.controller'; import { EverAsyncIntegrationService } from './ever-async-integration.service'; @@ -21,8 +22,8 @@ import { EverAsyncIntegrationService } from './ever-async-integration.service'; PluginCommonModule, RolePermissionModule ], - controllers: [EverAsyncController], - providers: [EverAsyncIntegrationService], + controllers: [EverAsyncController, EverAsyncConnectorController], + providers: [EverAsyncIntegrationService, EverAsyncConnectorGuard], exports: [EverAsyncIntegrationService] }) export class EverAsyncModule {} diff --git a/packages/ui-core/i18n/assets/i18n/en.json b/packages/ui-core/i18n/assets/i18n/en.json index 9e243b576c..1b6f6e14c1 100644 --- a/packages/ui-core/i18n/assets/i18n/en.json +++ b/packages/ui-core/i18n/assets/i18n/en.json @@ -1817,6 +1817,49 @@ "LOAD_FAILED": "Failed to load event triggers." } }, + "EVER_ASYNC_PAGE": { + "TITLE": "Ever Async", + "SETUP_INFO": "Connect this Gauzy organization to your Ever Async tenant. Choose which projects and employee task assignments may appear in your connected chats.", + "SELECT_ORGANIZATION": "Select an organization to load its connection settings.", + "SERVER_URL": "Ever Async server URL", + "URL_HINT": "Use the public HTTPS API URL of your Ever Async installation.", + "URL_INVALID": "Enter a valid HTTPS URL.", + "TEST_CONNECTION": "Check server", + "CONNECTION_OK": "Server is reachable. Configure the connector below to enable task context.", + "CONNECTION_FAILED": "The server could not be verified. Check its URL and availability.", + "PROJECTS": "Enabled projects", + "PROJECTS_HINT": "Only tasks in selected projects can be read, including tasks linked in chat. Selecting no projects shares no tasks.", + "NO_PROJECTS": "Create a project in this organization to share its task context.", + "MAPPINGS": "Chat members", + "MAPPINGS_HINT": "Map each chat identity to a Gauzy employee. Use the platform and verified workspace or server ID shown in Ever Async Connections. The same user ID in another workspace is a separate identity.", + "CHAT_USER": "Chat user ID", + "EMPLOYEE": "Employee", + "SELECT_EMPLOYEE": "Select an employee", + "REMOVE_MAPPING": "Remove mapping", + "ADD_MAPPING": "Add mapping", + "ENABLED": "Allow this connector to read task context", + "SAVE": "Save settings", + "CONNECT": "Create connection", + "SAVED": "Ever Async settings saved.", + "CONFIGURE_CONNECTOR": "Pair your Ever Async tenant", + "CONFIGURE_HINT": "Sign in to your Ever Async dashboard, select your tenant, and open Connections. Add an Ever Gauzy connection using the details below and the one-time credentials. Select which of your Slack and Discord connections may use it.", + "CONFIGURATION": "Connector configuration", + "SECRET_ONCE": "Save this secret now. It is shown only after creation or rotation and is never stored in plain text.", + "SHOW_SECRET": "Show secret", + "HIDE_SECRET": "Hide secret", + "ROTATE_HINT": "Rotating immediately invalidates the previous credential. Update Ever Async with the new secret before it can read tasks again.", + "ROTATE": "Rotate connector credential", + "SETUP_GUIDE": "Open the setup guide", + "CHAT_PLATFORM": "Chat platform", + "CHAT_WORKSPACE": "Workspace / server ID", + "GAUZY_API_URL": "Gauzy API URL", + "INTEGRATION_ID": "Integration ID", + "TENANT_ID": "Gauzy tenant ID", + "ORGANIZATION_ID": "Gauzy organization ID", + "SELF_HOSTED_CONFIG": "Optional configuration for a self-hosted server", + "API_KEY": "API key", + "API_SECRET": "API secret" + }, "PLANE_PAGE": { "TITLE": "Plane", "SUBTITLE": "Plane Project Management Integration", diff --git a/tsconfig.json b/tsconfig.json index 29976692e5..e3d67c379f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,7 +59,9 @@ "@gauzy/plugin-integration-zapier": ["./packages/plugins/integration-zapier/src/index.ts"], "@gauzy/plugin-integration-zapier-ui": ["./packages/plugins/integration-zapier-ui/src/index.ts"], "@gauzy/plugin-integration-plane": ["./packages/plugins/integration-plane/src/index.ts"], + "@gauzy/plugin-integration-ever-async": ["./packages/plugins/integration-ever-async/src/index.ts"], "@gauzy/plugin-integration-plane-ui": ["./packages/plugins/integration-plane-ui/src/index.ts"], + "@gauzy/plugin-integration-ever-async-ui": ["./packages/plugins/integration-ever-async-ui/src/index.ts"], "@gauzy/plugin-integration-sim": ["./packages/plugins/integration-sim/src/index.ts"], "@gauzy/plugin-integration-sim-ui": ["./packages/plugins/integration-sim-ui/src/index.ts"], "@gauzy/plugin-integration-upwork": ["./packages/plugins/integration-upwork/src/index.ts"],