diff --git a/backend/src/api/integration/helpers/hubspotConnect.ts b/backend/src/api/integration/helpers/hubspotConnect.ts index ab1a61fa5f..4b6332d3ae 100644 --- a/backend/src/api/integration/helpers/hubspotConnect.ts +++ b/backend/src/api/integration/helpers/hubspotConnect.ts @@ -3,7 +3,7 @@ import IntegrationService from '../../../services/integrationService' import PermissionChecker from '../../../services/user/permissionChecker' export default async (req, res) => { - new PermissionChecker(req).validateHas(Permissions.values.tenantEdit) + new PermissionChecker(req).validateHas(Permissions.values.integrationEdit) const payload = await new IntegrationService(req).hubspotConnect() await req.responseHandler.success(req, res, payload) } diff --git a/backend/src/api/integration/helpers/hubspotGetLists.ts b/backend/src/api/integration/helpers/hubspotGetLists.ts new file mode 100644 index 0000000000..9723f44ef4 --- /dev/null +++ b/backend/src/api/integration/helpers/hubspotGetLists.ts @@ -0,0 +1,9 @@ +import Permissions from '../../../security/permissions' +import IntegrationService from '../../../services/integrationService' +import PermissionChecker from '../../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.integrationEdit) + const payload = await new IntegrationService(req).hubspotGetLists() + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/integration/index.ts b/backend/src/api/integration/index.ts index faf7679a70..4c98042a45 100644 --- a/backend/src/api/integration/index.ts +++ b/backend/src/api/integration/index.ts @@ -102,6 +102,12 @@ export default (app) => { safeWrap(require('./helpers/hubspotGetMappableFields').default), ) + app.get( + '/tenant/:tenantId/hubspot-get-lists', + featureFlagMiddleware(FeatureFlag.HUBSPOT, 'hubspot.errors.notInPlan'), + safeWrap(require('./helpers/hubspotGetLists').default), + ) + app.post( '/tenant/:tenantId/hubspot-sync-member', featureFlagMiddleware(FeatureFlag.HUBSPOT, 'hubspot.errors.notInPlan'), diff --git a/backend/src/database/migrations/U1691074653__syncRemoteTables.sql b/backend/src/database/migrations/U1691074653__syncRemoteTables.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/database/migrations/U1691152131__syncRemoteTablesMissingIndexes.sql b/backend/src/database/migrations/U1691152131__syncRemoteTablesMissingIndexes.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/database/migrations/V1691074653__syncRemoteTables.sql b/backend/src/database/migrations/V1691074653__syncRemoteTables.sql new file mode 100644 index 0000000000..1354fbadae --- /dev/null +++ b/backend/src/database/migrations/V1691074653__syncRemoteTables.sql @@ -0,0 +1,30 @@ +create table "membersSyncRemote" ( + id uuid not null, + "memberId" uuid not null, + "sourceId" text, + "integrationId" uuid not null, + "syncFrom" text not null, + "metaData" text, + "lastSyncedAt" timestamptz, + "status" text not null, + constraint "membersSyncRemote_pkey" primary key (id), + foreign key ("memberId") references members (id) on delete cascade, + foreign key ("integrationId") references integrations (id) on delete cascade, + unique("memberId", "integrationId", "syncFrom") +); + + +create table "organizationsSyncRemote" ( + id uuid not null, + "organizationId" uuid not null, + "sourceId" text, + "integrationId" uuid not null, + "syncFrom" text not null, + "metaData" text, + "lastSyncedAt" timestamptz, + "status" text not null, + constraint "organizationsSyncRemote_pkey" primary key (id), + foreign key ("organizationId") references organizations (id) on delete cascade, + foreign key ("integrationId") references integrations (id) on delete cascade, + unique("organizationId", "integrationId", "syncFrom") +); \ No newline at end of file diff --git a/backend/src/database/migrations/V1691152131__syncRemoteTablesMissingIndexes.sql b/backend/src/database/migrations/V1691152131__syncRemoteTablesMissingIndexes.sql new file mode 100644 index 0000000000..cb57cb04c4 --- /dev/null +++ b/backend/src/database/migrations/V1691152131__syncRemoteTablesMissingIndexes.sql @@ -0,0 +1,11 @@ +create index if not exists "ix_membersSyncRemote_memberId" on "membersSyncRemote" ("memberId"); +create index if not exists "ix_membersSyncRemote_integrationId" on "membersSyncRemote" ("integrationId"); +create index if not exists "ix_membersSyncRemote_syncFrom" on "membersSyncRemote" ("syncFrom"); +create index if not exists "ix_membersSyncRemote_sourceId" on "membersSyncRemote" ("sourceId"); +create index if not exists "ix_membersSyncRemote_status" on "membersSyncRemote" ("status"); + +create index if not exists "ix_organizationsSyncRemote_organizationId" on "organizationsSyncRemote" ("organizationId"); +create index if not exists "ix_organizationsSyncRemote_integrationId" on "organizationsSyncRemote" ("integrationId"); +create index if not exists "ix_organizationsSyncRemote_syncFrom" on "organizationsSyncRemote" ("syncFrom"); +create index if not exists "ix_organizationsSyncRemote_sourceId" on "organizationsSyncRemote" ("sourceId"); +create index if not exists "ix_organizationsSyncRemote_status" on "organizationsSyncRemote" ("status"); \ No newline at end of file diff --git a/backend/src/database/repositories/automationExecutionRepository.ts b/backend/src/database/repositories/automationExecutionRepository.ts index 0cdacdfd60..a2482c0d88 100644 --- a/backend/src/database/repositories/automationExecutionRepository.ts +++ b/backend/src/database/repositories/automationExecutionRepository.ts @@ -137,6 +137,29 @@ export default class AutomationExecutionRepository extends RepositoryBase< throw new Error('Method not implemented.') } + async destroyAllAutomation(automationIds: string[]): Promise { + const transaction = this.transaction + + const seq = this.seq + + const currentTenant = this.currentTenant + + const query = ` + delete + from "automationExecutions" + where "automationId" in (:automationIds) + and "tenantId" = :tenantId;` + + await seq.query(query, { + replacements: { + automationIds, + tenantId: currentTenant.id, + }, + type: QueryTypes.DELETE, + transaction, + }) + } + override async destroyAll(ids: string[]): Promise { throw new Error('Method not implemented.') } diff --git a/backend/src/database/repositories/automationRepository.ts b/backend/src/database/repositories/automationRepository.ts index 45b90469f1..b6a10812bd 100644 --- a/backend/src/database/repositories/automationRepository.ts +++ b/backend/src/database/repositories/automationRepository.ts @@ -1,4 +1,5 @@ import Sequelize, { QueryTypes } from 'sequelize' +import { AutomationSyncTrigger, IAutomation } from '@crowd/types' import AuditLogRepository from './auditLogRepository' import { IRepositoryOptions } from './IRepositoryOptions' import Error404 from '../../errors/Error404' @@ -246,4 +247,46 @@ export default class AutomationRepository extends RepositoryBase< return automationCount } + + public async findSyncAutomations( + tenantId: string, + platform: string, + ): Promise { + const seq = this.seq + + const transaction = this.transaction + + const pageSize = 10 + const syncAutomations: IAutomation[] = [] + + let results + let offset + + do { + offset = results ? pageSize + offset : 0 + + results = await seq.query( + `select * from automations + where type = :platform and "tenantId" = :tenantId and trigger in (:syncAutomationTriggers) + limit :limit offset :offset`, + { + replacements: { + tenantId, + platform, + syncAutomationTriggers: [ + AutomationSyncTrigger.MEMBER_ATTRIBUTES_MATCH, + AutomationSyncTrigger.ORGANIZATION_ATTRIBUTES_MATCH, + ], + limit: pageSize, + offset, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + syncAutomations.push(...results) + } while (results.length > 0) + + return syncAutomations + } } diff --git a/backend/src/database/repositories/integrationRepository.ts b/backend/src/database/repositories/integrationRepository.ts index 09d5a580a3..fceb29a80a 100644 --- a/backend/src/database/repositories/integrationRepository.ts +++ b/backend/src/database/repositories/integrationRepository.ts @@ -8,6 +8,10 @@ import Error404 from '../../errors/Error404' import { IRepositoryOptions } from './IRepositoryOptions' import QueryParser from './filters/queryParser' import { QueryOutput } from './filters/queryTypes' +import AutomationRepository from './automationRepository' +import AutomationExecutionRepository from './automationExecutionRepository' +import MemberSyncRemoteRepository from './memberSyncRemoteRepository' +import OrganizationSyncRemoteRepository from './organizationSyncRemoteRepository' const { Op } = Sequelize const log: boolean = false @@ -152,6 +156,30 @@ class IntegrationRepository { }, ) + // delete syncRemote rows coming from integration + await new MemberSyncRemoteRepository({ ...options, transaction }).destroyAllIntegration([ + record.id, + ]) + await new OrganizationSyncRemoteRepository({ ...options, transaction }).destroyAllIntegration([ + record.id, + ]) + + // destroy existing automations for outgoing integrations + const syncAutomationIds = ( + await new AutomationRepository({ ...options, transaction }).findSyncAutomations( + currentTenant.id, + record.platform, + ) + ).map((a) => a.id) + + if (syncAutomationIds.length > 0) { + await new AutomationExecutionRepository({ ...options, transaction }).destroyAllAutomation( + syncAutomationIds, + ) + } + + await new AutomationRepository({ ...options, transaction }).destroyAll(syncAutomationIds) + await this._createAuditLog(AuditLogRepository.DELETE, record, record, options) } diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index c21e057506..1d4df30500 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -4,6 +4,7 @@ import { MemberAttributeType, OpenSearchIndex, PlatformType, + SyncStatus, } from '@crowd/types' import lodash, { chunk } from 'lodash' import Sequelize, { QueryTypes } from 'sequelize' @@ -45,6 +46,7 @@ import { } from './types/memberTypes' import Error400 from '../../errors/Error400' import OrganizationRepository from './organizationRepository' +import MemberSyncRemoteRepository from './memberSyncRemoteRepository' const { Op } = Sequelize @@ -628,6 +630,11 @@ class MemberRepository { throw new Error404() } + // exclude syncRemote attributes, since these are populated from memberSyncRemote table + if (data.attributes?.syncRemote) { + delete data.attributes.syncRemote + } + record = await record.update( { ...lodash.pick(data, [ @@ -3215,6 +3222,21 @@ class MemberRepository { output.affiliations = await this.getAffiliations(record.id, options) + const manualSyncRemote = await new MemberSyncRemoteRepository({ + ...options, + transaction, + }).findMemberManualSync(record.id) + + for (const syncRemote of manualSyncRemote) { + if (output.attributes?.syncRemote) { + output.attributes.syncRemote[syncRemote.platform] = syncRemote.status === SyncStatus.ACTIVE + } else { + output.attributes.syncRemote = { + [syncRemote.platform]: syncRemote.status === SyncStatus.ACTIVE, + } + } + } + return output } diff --git a/backend/src/database/repositories/memberSyncRemoteRepository.ts b/backend/src/database/repositories/memberSyncRemoteRepository.ts new file mode 100644 index 0000000000..8d70daa232 --- /dev/null +++ b/backend/src/database/repositories/memberSyncRemoteRepository.ts @@ -0,0 +1,248 @@ +import { generateUUIDv1 as uuid } from '@crowd/common' +import { IMemberSyncRemoteData, SyncStatus } from '@crowd/types' +import { QueryTypes } from 'sequelize' +import { IRepositoryOptions } from './IRepositoryOptions' +import { RepositoryBase } from './repositoryBase' +import SequelizeRepository from './sequelizeRepository' + +class MemberSyncRemoteRepository extends RepositoryBase< + IMemberSyncRemoteData, + string, + IMemberSyncRemoteData, + unknown, + unknown +> { + public constructor(options: IRepositoryOptions) { + super(options, true) + } + + async stopSyncingAutomation(automationId: string) { + await this.options.database.sequelize.query( + `update "membersSyncRemote" set status = :status where "syncFrom" = :automationId + `, + { + replacements: { + status: SyncStatus.STOPPED, + automationId, + }, + type: QueryTypes.UPDATE, + }, + ) + } + + async findRemoteSync(integrationId: string, memberId: string, syncFrom: string) { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `SELECT * + FROM "membersSyncRemote" + WHERE "integrationId" = :integrationId and "memberId" = :memberId and "syncFrom" = :syncFrom; + `, + { + replacements: { + integrationId, + memberId, + syncFrom, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + if (records.length === 0) { + return null + } + + return records[0] + } + + async startManualSync(id: string, sourceId: string) { + const transaction = SequelizeRepository.getTransaction(this.options) + + await this.options.database.sequelize.query( + `update "membersSyncRemote" set status = :status, "sourceId" = :sourceId where "id" = :id + `, + { + replacements: { + status: SyncStatus.ACTIVE, + id, + sourceId: sourceId || null, + }, + type: QueryTypes.UPDATE, + transaction, + }, + ) + } + + async stopMemberManualSync(memberId: string) { + await this.options.database.sequelize.query( + `update "membersSyncRemote" set status = :status where "memberId" = :memberId and "syncFrom" = :manualSync + `, + { + replacements: { + status: SyncStatus.STOPPED, + memberId, + manualSync: 'manual', + }, + type: QueryTypes.UPDATE, + }, + ) + } + + async destroyAllAutomation(automationIds: string[]): Promise { + const transaction = this.transaction + + const seq = this.seq + + const query = ` + delete + from "membersSyncRemote" + where "syncFrom" in (:automationIds);` + + await seq.query(query, { + replacements: { + automationIds, + }, + type: QueryTypes.DELETE, + transaction, + }) + } + + async destroyAllIntegration(integrationIds: string[]): Promise { + const transaction = this.transaction + + const seq = this.seq + + const query = ` + delete + from "membersSyncRemote" + where "integrationId" in (:integrationIds);` + + await seq.query(query, { + replacements: { + integrationIds, + }, + type: QueryTypes.DELETE, + transaction, + }) + } + + async markMemberForSyncing(data: IMemberSyncRemoteData): Promise { + const transaction = SequelizeRepository.getTransaction(this.options) + + const existingSyncRemote = await this.findByMemberId(data.memberId) + + if (existingSyncRemote) { + data.sourceId = existingSyncRemote.sourceId + } + + const existingManualSyncRemote = await this.findRemoteSync( + data.integrationId, + data.memberId, + data.syncFrom, + ) + + if (existingManualSyncRemote) { + await this.startManualSync(existingManualSyncRemote.id, data.sourceId) + return existingManualSyncRemote + } + + const memberSyncRemoteInserted = await this.options.database.sequelize.query( + `insert into "membersSyncRemote" ("id", "memberId", "sourceId", "integrationId", "syncFrom", "metaData", "lastSyncedAt", "status") + values + (:id, :memberId, :sourceId, :integrationId, :syncFrom, :metaData, :lastSyncedAt, :status) + returning "id" + `, + { + replacements: { + id: uuid(), + memberId: data.memberId, + integrationId: data.integrationId, + syncFrom: data.syncFrom, + metaData: data.metaData, + lastSyncedAt: data.lastSyncedAt || null, + sourceId: data.sourceId || null, + status: SyncStatus.ACTIVE, + }, + type: QueryTypes.INSERT, + transaction, + }, + ) + + const memberSyncRemote = await this.findById(memberSyncRemoteInserted[0][0].id) + return memberSyncRemote + } + + async findMemberManualSync(memberId: string) { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `select i.platform, msr.status from "membersSyncRemote" msr + inner join integrations i on msr."integrationId" = i.id + where msr."syncFrom" = :syncFrom and msr."memberId" = :memberId; + `, + { + replacements: { + memberId, + syncFrom: 'manual', + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + return records + } + + async findByMemberId(memberId: string): Promise { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `SELECT * + FROM "membersSyncRemote" + WHERE "memberId" = :memberId + and "sourceId" is not null + limit 1; + `, + { + replacements: { + memberId, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + if (records.length === 0) { + return null + } + + return records[0] + } + + async findById(id: string): Promise { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `SELECT * + FROM "membersSyncRemote" + WHERE id = :id; + `, + { + replacements: { + id, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + if (records.length === 0) { + return null + } + + return records[0] + } +} + +export default MemberSyncRemoteRepository diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index cbda4fbf37..3753ad6285 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -1,4 +1,5 @@ import lodash from 'lodash' +import { SyncStatus } from '@crowd/types' import Sequelize, { QueryTypes } from 'sequelize' import SequelizeRepository from './sequelizeRepository' import AuditLogRepository from './auditLogRepository' @@ -7,6 +8,7 @@ import Error404 from '../../errors/Error404' import { IRepositoryOptions } from './IRepositoryOptions' import QueryParser from './filters/queryParser' import { QueryOutput } from './filters/queryTypes' +import OrganizationSyncRemoteRepository from './organizationSyncRemoteRepository' const { Op } = Sequelize @@ -222,6 +224,11 @@ class OrganizationRepository { throw new Error404() } + // exclude syncRemote attributes, since these are populated from organizationSyncRemote table + if (data.attributes?.syncRemote) { + delete data.attributes.syncRemote + } + record = await record.update( { ...lodash.pick(data, [ @@ -436,7 +443,24 @@ class OrganizationRepository { throw new Error404() } - return results[0] as any + const result = results[0] as any + + const manualSyncRemote = await new OrganizationSyncRemoteRepository({ + ...options, + transaction, + }).findOrganizationManualSync(result.id) + + for (const syncRemote of manualSyncRemote) { + if (result.attributes?.syncRemote) { + result.attributes.syncRemote[syncRemote.platform] = syncRemote.status === SyncStatus.ACTIVE + } else { + result.attributes.syncRemote = { + [syncRemote.platform]: syncRemote.status === SyncStatus.ACTIVE, + } + } + } + + return result } static async findByName(name, options: IRepositoryOptions) { diff --git a/backend/src/database/repositories/organizationSyncRemoteRepository.ts b/backend/src/database/repositories/organizationSyncRemoteRepository.ts new file mode 100644 index 0000000000..da2db18882 --- /dev/null +++ b/backend/src/database/repositories/organizationSyncRemoteRepository.ts @@ -0,0 +1,250 @@ +import { generateUUIDv1 as uuid } from '@crowd/common' +import { IOrganizationSyncRemoteData, SyncStatus } from '@crowd/types' +import { QueryTypes } from 'sequelize' +import { IRepositoryOptions } from './IRepositoryOptions' +import { RepositoryBase } from './repositoryBase' +import SequelizeRepository from './sequelizeRepository' + +class OrganizationSyncRemoteRepository extends RepositoryBase< + IOrganizationSyncRemoteData, + string, + IOrganizationSyncRemoteData, + unknown, + unknown +> { + public constructor(options: IRepositoryOptions) { + super(options, true) + } + + async stopSyncingAutomation(automationId: string) { + await this.options.database.sequelize.query( + `update "organizationsSyncRemote" set status = :status where "syncFrom" = :automationId + `, + { + replacements: { + status: SyncStatus.STOPPED, + automationId, + }, + type: QueryTypes.UPDATE, + }, + ) + } + + async stopOrganizationManualSync(organizationId: string) { + await this.options.database.sequelize.query( + `update "organizationsSyncRemote" set status = :status where "organizationId" = :organizationId and "syncFrom" = :manualSync + `, + { + replacements: { + status: SyncStatus.STOPPED, + organizationId, + manualSync: 'manual', + }, + type: QueryTypes.UPDATE, + }, + ) + } + + async startManualSync(id: string, sourceId: string) { + const transaction = SequelizeRepository.getTransaction(this.options) + + await this.options.database.sequelize.query( + `update "organizationsSyncRemote" set status = :status, "sourceId" = :sourceId where "id" = :id + `, + { + replacements: { + status: SyncStatus.ACTIVE, + id, + sourceId: sourceId || null, + }, + type: QueryTypes.UPDATE, + transaction, + }, + ) + } + + async findRemoteSync(integrationId: string, organizationId: string, syncFrom: string) { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `SELECT * + FROM "organizationsSyncRemote" + WHERE "integrationId" = :integrationId and "organizationId" = :organizationId and "syncFrom" = :syncFrom; + `, + { + replacements: { + integrationId, + organizationId, + syncFrom, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + if (records.length === 0) { + return null + } + + return records[0] + } + + async markOrganizationForSyncing( + data: IOrganizationSyncRemoteData, + ): Promise { + const transaction = SequelizeRepository.getTransaction(this.options) + + const existingSyncRemote = await this.findByOrganizationId(data.organizationId) + + if (existingSyncRemote) { + data.sourceId = existingSyncRemote.sourceId + } + + const existingManualSyncRemote = await this.findRemoteSync( + data.integrationId, + data.organizationId, + data.syncFrom, + ) + + if (existingManualSyncRemote) { + await this.startManualSync(existingManualSyncRemote.id, data.sourceId) + return existingManualSyncRemote + } + + const organizationSyncRemoteInserted = await this.options.database.sequelize.query( + `insert into "organizationsSyncRemote" ("id", "organizationId", "sourceId", "integrationId", "syncFrom", "metaData", "lastSyncedAt", "status") + VALUES + (:id, :organizationId, :sourceId, :integrationId, :syncFrom, :metaData, :lastSyncedAt, :status) + returning "id" + `, + { + replacements: { + id: uuid(), + organizationId: data.organizationId, + integrationId: data.integrationId, + syncFrom: data.syncFrom, + metaData: data.metaData, + lastSyncedAt: data.lastSyncedAt || null, + sourceId: data.sourceId || null, + status: SyncStatus.ACTIVE, + }, + type: QueryTypes.INSERT, + transaction, + }, + ) + + const organizationSyncRemote = await this.findById(organizationSyncRemoteInserted[0][0].id) + return organizationSyncRemote + } + + async destroyAllAutomation(automationIds: string[]): Promise { + const transaction = this.transaction + + const seq = this.seq + + const query = ` + delete + from "organizationsSyncRemote" + where "syncFrom" in (:automationIds);` + + await seq.query(query, { + replacements: { + automationIds, + }, + type: QueryTypes.DELETE, + transaction, + }) + } + + async destroyAllIntegration(integrationIds: string[]): Promise { + const transaction = this.transaction + + const seq = this.seq + + const query = ` + delete + from "organizationsSyncRemote" + where "integrationId" in (:integrationIds);` + + await seq.query(query, { + replacements: { + integrationIds, + }, + type: QueryTypes.DELETE, + transaction, + }) + } + + async findOrganizationManualSync(organizationId: string) { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `select i.platform, osr.status from "organizationsSyncRemote" osr + inner join integrations i on osr."integrationId" = i.id + where osr."syncFrom" = :syncFrom and osr."organizationId" = :organizationId; + `, + { + replacements: { + organizationId, + syncFrom: 'manual', + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + return records + } + + async findByOrganizationId(organizationId: string): Promise { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `SELECT * + FROM "organizationsSyncRemote" + WHERE "organizationId" = :organizationId + and "sourceId" is not null + limit 1; + `, + { + replacements: { + organizationId, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + if (records.length === 0) { + return null + } + + return records[0] + } + + async findById(id: string): Promise { + const transaction = SequelizeRepository.getTransaction(this.options) + + const records = await this.options.database.sequelize.query( + `SELECT * + FROM "organizationsSyncRemote" + WHERE id = :id; + `, + { + replacements: { + id, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + if (records.length === 0) { + return null + } + + return records[0] + } +} + +export default OrganizationSyncRemoteRepository diff --git a/backend/src/database/repositories/types/automationTypes.ts b/backend/src/database/repositories/types/automationTypes.ts index b14892bd3c..a066d8d98d 100644 --- a/backend/src/database/repositories/types/automationTypes.ts +++ b/backend/src/database/repositories/types/automationTypes.ts @@ -1,3 +1,4 @@ +import { AutomationSyncTrigger } from '@crowd/types' import { AutomationExecutionState, AutomationSettings, @@ -9,7 +10,7 @@ import { export interface DbAutomationInsertData { name: string type: AutomationType - trigger: AutomationTrigger + trigger: AutomationTrigger | AutomationSyncTrigger settings: AutomationSettings state: AutomationState } @@ -25,7 +26,7 @@ export interface DbAutomationExecutionInsertData { automationId: string type: AutomationType tenantId: string - trigger: AutomationTrigger + trigger: AutomationTrigger | AutomationSyncTrigger state: AutomationExecutionState error: any | null executedAt: Date diff --git a/backend/src/services/automationService.ts b/backend/src/services/automationService.ts index aa5bf2b7e4..fcd6aca4ee 100644 --- a/backend/src/services/automationService.ts +++ b/backend/src/services/automationService.ts @@ -1,7 +1,9 @@ +import { AutomationSyncTrigger, PlatformType } from '@crowd/types' import { AutomationCriteria, AutomationData, AutomationState, + AutomationType, CreateAutomationRequest, UpdateAutomationRequest, } from '../types/automationTypes' @@ -10,6 +12,12 @@ import SequelizeRepository from '../database/repositories/sequelizeRepository' import AutomationRepository from '../database/repositories/automationRepository' import { PageData } from '../types/common' import { ServiceBase } from './serviceBase' +import { getIntegrationSyncWorkerEmitter } from '@/serverless/utils/serviceSQS' +import IntegrationRepository from '@/database/repositories/integrationRepository' +import Error404 from '@/errors/Error404' +import MemberSyncRemoteRepository from '@/database/repositories/memberSyncRemoteRepository' +import OrganizationSyncRemoteRepository from '@/database/repositories/organizationSyncRemoteRepository' +import AutomationExecutionRepository from '@/database/repositories/automationExecutionRepository' export default class AutomationService extends ServiceBase< AutomationData, @@ -37,6 +45,40 @@ export default class AutomationService extends ServiceBase< state: AutomationState.ACTIVE, }) + // check automation type, if hubspot trigger an automation onboard + if (req.type === AutomationType.HUBSPOT) { + let integration + + try { + integration = await IntegrationRepository.findByPlatform(PlatformType.HUBSPOT, { + ...this.options, + }) + } catch (err) { + this.options.log.error(err, 'Error while fetching HubSpot integration from DB!') + throw new Error404() + } + + // enable sync remote for integration + integration = await IntegrationRepository.update( + integration.id, + { + settings: { + ...integration.settings, + syncRemoteEnabled: true, + }, + }, + txOptions, + ) + + const integrationSyncWorkerEmitter = await getIntegrationSyncWorkerEmitter() + await integrationSyncWorkerEmitter.triggerOnboardAutomation( + this.options.currentTenant.id, + integration.id, + result.id, + req.trigger as AutomationSyncTrigger, + ) + } + await SequelizeRepository.commitTransaction(txOptions.transaction) return result @@ -62,6 +104,42 @@ export default class AutomationService extends ServiceBase< // update an existing automation including its state const result = await new AutomationRepository(txOptions).update(id, req) await SequelizeRepository.commitTransaction(txOptions.transaction) + // check automation type, if hubspot trigger an automation onboard + if (result.type === AutomationType.HUBSPOT) { + let integration + + try { + integration = await IntegrationRepository.findByPlatform(PlatformType.HUBSPOT, { + ...this.options, + }) + } catch (err) { + this.options.log.error(err, 'Error while fetching HubSpot integration from DB!') + throw new Error404() + } + + if ( + result.trigger === AutomationSyncTrigger.MEMBER_ATTRIBUTES_MATCH || + result.trigger === AutomationSyncTrigger.ORGANIZATION_ATTRIBUTES_MATCH + ) { + if (result.state === AutomationState.ACTIVE) { + const integrationSyncWorkerEmitter = await getIntegrationSyncWorkerEmitter() + await integrationSyncWorkerEmitter.triggerOnboardAutomation( + this.options.currentTenant.id, + integration.id, + result.id, + result.trigger as AutomationSyncTrigger, + ) + } else if (result.trigger === AutomationSyncTrigger.MEMBER_ATTRIBUTES_MATCH) { + // disable memberSyncRemote for given automationId + const syncRepo = new MemberSyncRemoteRepository(this.options) + await syncRepo.stopSyncingAutomation(result.id) + } else if (result.trigger === AutomationSyncTrigger.ORGANIZATION_ATTRIBUTES_MATCH) { + // disable organizationSyncRemote for given automationId + const syncRepo = new OrganizationSyncRemoteRepository(this.options) + await syncRepo.stopSyncingAutomation(result.id) + } + } + } return result } catch (error) { await SequelizeRepository.rollbackTransaction(txOptions.transaction) @@ -95,6 +173,13 @@ export default class AutomationService extends ServiceBase< const txOptions = await this.getTxRepositoryOptions() try { + // delete automation executions + await new AutomationExecutionRepository(txOptions).destroyAllAutomation(ids) + + // delete syncRemote rows coming from automations + await new MemberSyncRemoteRepository(txOptions).destroyAllAutomation(ids) + await new OrganizationSyncRemoteRepository(txOptions).destroyAllAutomation(ids) + const result = await new AutomationRepository(txOptions).destroyAll(ids) await SequelizeRepository.commitTransaction(txOptions.transaction) return result diff --git a/backend/src/services/integrationService.ts b/backend/src/services/integrationService.ts index 2781601c3a..f4b5c49beb 100644 --- a/backend/src/services/integrationService.ts +++ b/backend/src/services/integrationService.ts @@ -13,6 +13,7 @@ import { IHubspotTokenInfo, HubspotEndpoint, IHubspotManualSyncPayload, + getHubspotLists, } from '@crowd/integrations' import { ILinkedInOrganization } from '../serverless/integrations/types/linkedinTypes' import { DISCORD_CONFIG, GITHUB_CONFIG, IS_TEST_ENV, KUBE_MODE, NANGO_CONFIG } from '../conf/index' @@ -34,11 +35,15 @@ import { IntegrationRunState } from '../types/integrationRunTypes' import { getIntegrationRunWorkerEmitter, getIntegrationSyncWorkerEmitter, + getSearchSyncWorkerEmitter, } from '../serverless/utils/serviceSQS' import MemberAttributeSettingsRepository from '../database/repositories/memberAttributeSettingsRepository' import TenantRepository from '../database/repositories/tenantRepository' import MemberService from './memberService' import OrganizationService from './organizationService' +import MemberSyncRemoteRepository from '@/database/repositories/memberSyncRemoteRepository' +import OrganizationSyncRemoteRepository from '@/database/repositories/organizationSyncRemoteRepository' +import MemberRepository from '@/database/repositories/memberRepository' const discordToken = DISCORD_CONFIG.token2 || DISCORD_CONFIG.token @@ -468,22 +473,25 @@ export default class IntegrationService { throw new Error('memberId is required in the payload while syncing member to hubspot!') } - // update member.attributes.syncRemote.hubspot to false - const memberService = new MemberService(this.options) + const transaction = await SequelizeRepository.createTransaction(this.options) - const member = await memberService.findById(payload.memberId) + try { + const memberService = new MemberService(this.options) - if (!member.attributes.syncRemote) { - member.attributes.syncRemote = { - default: false, - [PlatformType.HUBSPOT]: false, - } - } else { - member.attributes.syncRemote[PlatformType.HUBSPOT] = false - member.attributes.syncRemote.default = false - } + const member = await memberService.findById(payload.memberId) + + const memberSyncRemoteRepository = new MemberSyncRemoteRepository({ + ...this.options, + transaction, + }) + await memberSyncRemoteRepository.stopMemberManualSync(member.id) - await memberService.update(payload.memberId, { attributes: member.attributes }) + await SequelizeRepository.commitTransaction(transaction) + } catch (err) { + this.options.log.error(err, 'Error while stopping hubspot member sync!') + await SequelizeRepository.rollbackTransaction(transaction) + throw err + } } async hubspotSyncMember(payload: IHubspotManualSyncPayload) { @@ -491,35 +499,30 @@ export default class IntegrationService { throw new Error('memberId is required in the payload while syncing member to hubspot!') } + const transaction = await SequelizeRepository.createTransaction(this.options) + let integration + let member + let memberSyncRemote try { integration = await IntegrationRepository.findByPlatform(PlatformType.HUBSPOT, { ...this.options, + transaction, }) - } catch (err) { - this.options.log.error(err, 'Error while fetching HubSpot integration from DB!') - throw new Error404() - } - // update member.attributes.syncRemote.hubspot to true - const memberService = new MemberService(this.options) - const member = await memberService.findById(payload.memberId) + member = await MemberRepository.findById(payload.memberId, { ...this.options, transaction }) - if (!member.attributes.syncRemote) { - member.attributes.syncRemote = { - default: true, - [PlatformType.HUBSPOT]: true, - } - } else { - member.attributes.syncRemote[PlatformType.HUBSPOT] = true - member.attributes.syncRemote.default = true - } + const memberSyncRemoteRepo = new MemberSyncRemoteRepository({ ...this.options, transaction }) - const transaction = await SequelizeRepository.createTransaction(this.options) + memberSyncRemote = await memberSyncRemoteRepo.markMemberForSyncing({ + integrationId: integration.id, + memberId: member.id, + metaData: null, + syncFrom: 'manual', + lastSyncedAt: null, + }) - // set integration.settings.syncRemoteEnabled to true, and mark member as syncRemote - try { integration = await this.createOrUpdate( { platform: PlatformType.HUBSPOT, @@ -530,10 +533,10 @@ export default class IntegrationService { }, transaction, ) - await memberService.update(payload.memberId, { attributes: member.attributes }) await SequelizeRepository.commitTransaction(transaction) } catch (err) { + this.options.log.error(err, 'Error while starting Hubspot member sync!') await SequelizeRepository.rollbackTransaction(transaction) throw err } @@ -543,7 +546,12 @@ export default class IntegrationService { this.options.currentTenant.id, integration.id, payload.memberId, + memberSyncRemote.id, ) + + // send it to opensearch because in member.update we bypass while passing transactions + const searchSyncEmitter = await getSearchSyncWorkerEmitter() + await searchSyncEmitter.triggerMemberSync(this.options.currentTenant.id, member.id) } async hubspotStopSyncOrganization(payload: IHubspotManualSyncPayload) { @@ -553,24 +561,23 @@ export default class IntegrationService { ) } - // update organization.attributes.syncRemote.hubspot to false - const organizationService = new OrganizationService(this.options) + const transaction = await SequelizeRepository.createTransaction(this.options) - const organization = await organizationService.findById(payload.organizationId) + try { + const organizationService = new OrganizationService(this.options) - if (!organization.attributes.syncRemote) { - organization.attributes.syncRemote = { - default: false, - [PlatformType.HUBSPOT]: false, - } - } else { - organization.attributes.syncRemote[PlatformType.HUBSPOT] = false - organization.attributes.syncRemote.default = false - } + const organization = await organizationService.findById(payload.organizationId) - await organizationService.update(payload.organizationId, { - attributes: organization.attributes, - }) + const organizationSyncRemoteRepository = new OrganizationSyncRemoteRepository({ + ...this.options, + transaction, + }) + await organizationSyncRemoteRepository.stopOrganizationManualSync(organization.id) + } catch (err) { + this.options.log.error(err, 'Error while stopping Hubspot organization sync!') + await SequelizeRepository.rollbackTransaction(transaction) + throw err + } } async hubspotSyncOrganization(payload: IHubspotManualSyncPayload) { @@ -580,42 +587,35 @@ export default class IntegrationService { ) } + const transaction = await SequelizeRepository.createTransaction(this.options) + let integration + let organization + let organizationSyncRemote try { integration = await IntegrationRepository.findByPlatform(PlatformType.HUBSPOT, { ...this.options, + transaction, }) - } catch (err) { - this.options.log.error(err, 'Error while fetching HubSpot integration from DB!') - throw new Error404() - } - // update organization.attributes.syncRemote.hubspot to true - const organizationService = new OrganizationService(this.options) - const organization = await organizationService.findById(payload.organizationId) + const organizationService = new OrganizationService(this.options) - if (!organization.attributes) { - organization.attributes = { - syncRemote: { - default: true, - [PlatformType.HUBSPOT]: true, - }, - } - } else if (!organization.attributes.syncRemote) { - organization.attributes.syncRemote = { - default: true, - [PlatformType.HUBSPOT]: true, - } - } else { - organization.attributes.syncRemote[PlatformType.HUBSPOT] = true - organization.attributes.syncRemote.default = true - } + organization = await organizationService.findById(payload.organizationId) - const transaction = await SequelizeRepository.createTransaction(this.options) + const organizationSyncRemoteRepo = new OrganizationSyncRemoteRepository({ + ...this.options, + transaction, + }) + + organizationSyncRemote = await organizationSyncRemoteRepo.markOrganizationForSyncing({ + integrationId: integration.id, + organizationId: organization.id, + metaData: null, + syncFrom: 'manual', + lastSyncedAt: null, + }) - // set integration.settings.syncRemoteEnabled to true, and mark organization as syncRemote - try { integration = await this.createOrUpdate( { platform: PlatformType.HUBSPOT, @@ -626,22 +626,21 @@ export default class IntegrationService { }, transaction, ) - await organizationService.update(payload.organizationId, { - attributes: organization.attributes, - }) await SequelizeRepository.commitTransaction(transaction) + + const integrationSyncWorkerEmitter = await getIntegrationSyncWorkerEmitter() + await integrationSyncWorkerEmitter.triggerSyncOrganization( + this.options.currentTenant.id, + integration.id, + payload.organizationId, + organizationSyncRemote.id, + ) } catch (err) { + this.options.log.error(err, 'Error while starting Hubspot organization sync!') await SequelizeRepository.rollbackTransaction(transaction) throw err } - - const integrationSyncWorkerEmitter = await getIntegrationSyncWorkerEmitter() - await integrationSyncWorkerEmitter.triggerSyncOrganization( - this.options.currentTenant.id, - integration.id, - payload.organizationId, - ) } async hubspotOnboard(onboardSettings: IHubspotOnboardingSettings) { @@ -768,6 +767,39 @@ export default class IntegrationService { ) } + async hubspotGetLists() { + const tenantId = this.options.currentTenant.id + const nangoId = `${tenantId}-${PlatformType.HUBSPOT}` + + let token: string + try { + token = await getToken(nangoId, PlatformType.HUBSPOT, this.options.log) + } catch (err) { + this.options.log.error(err, 'Error while verifying HubSpot tenant token in Nango!') + throw new Error400(this.options.language, 'errors.noNangoToken.message') + } + + if (!token) { + throw new Error400(this.options.language, 'errors.noNangoToken.message') + } + + const context = { + log: this.options.log, + serviceSettings: { + nangoId, + nangoUrl: NANGO_CONFIG.url, + nangoSecretKey: NANGO_CONFIG.secretKey, + }, + } + + const memberLists = await getHubspotLists(nangoId, context) + + return { + members: memberLists, + organizations: [], // hubspot doesn't support company lists yet + } + } + async hubspotGetMappableFields() { const memberAttributeSettings = ( await MemberAttributeSettingsRepository.findAndCountAll({}, this.options) @@ -783,7 +815,7 @@ export default class IntegrationService { HubspotEntity.MEMBERS, null, memberAttributeSettings, - identities, + identities.map((i) => i.platform), ) const organizationMapper = HubspotFieldMapperFactory.getFieldMapper( HubspotEntity.ORGANIZATIONS, diff --git a/backend/src/services/memberService.ts b/backend/src/services/memberService.ts index c8fd293d3d..989c5c8200 100644 --- a/backend/src/services/memberService.ts +++ b/backend/src/services/memberService.ts @@ -834,8 +834,9 @@ export default class MemberService extends LoggerBase { } } - async update(id, data) { - const transaction = await SequelizeRepository.createTransaction(this.options) + async update(id, data, passedTransaction?) { + const transaction = + passedTransaction || (await SequelizeRepository.createTransaction(this.options)) const searchSyncEmitter = await getSearchSyncWorkerEmitter() try { @@ -905,9 +906,10 @@ export default class MemberService extends LoggerBase { transaction, }) - await SequelizeRepository.commitTransaction(transaction) - - await searchSyncEmitter.triggerMemberSync(this.options.currentTenant.id, record.id) + if (!passedTransaction) { + await SequelizeRepository.commitTransaction(transaction) + await searchSyncEmitter.triggerMemberSync(this.options.currentTenant.id, record.id) + } return record } catch (error) { diff --git a/backend/src/services/organizationService.ts b/backend/src/services/organizationService.ts index ade9b550c2..a52e05d126 100644 --- a/backend/src/services/organizationService.ts +++ b/backend/src/services/organizationService.ts @@ -130,8 +130,9 @@ export default class OrganizationService extends LoggerBase { } } - async update(id, data) { - const transaction = await SequelizeRepository.createTransaction(this.options) + async update(id, data, passedTransaction?) { + const transaction = + passedTransaction || (await SequelizeRepository.createTransaction(this.options)) try { if (data.members) { @@ -146,7 +147,9 @@ export default class OrganizationService extends LoggerBase { transaction, }) - await SequelizeRepository.commitTransaction(transaction) + if (!passedTransaction) { + await SequelizeRepository.commitTransaction(transaction) + } return record } catch (error) { diff --git a/backend/src/types/automationTypes.ts b/backend/src/types/automationTypes.ts index cd2797c170..9921c79fa1 100644 --- a/backend/src/types/automationTypes.ts +++ b/backend/src/types/automationTypes.ts @@ -1,11 +1,13 @@ /** * all automation types that we are currently supporting */ +import { AutomationSyncTrigger } from '@crowd/types' import { SearchCriteria } from './common' export enum AutomationType { WEBHOOK = 'webhook', SLACK = 'slack', + HUBSPOT = 'hubspot', } /** @@ -69,7 +71,7 @@ export interface AutomationData { name: string type: AutomationType tenantId: string - trigger: AutomationTrigger + trigger: AutomationTrigger | AutomationSyncTrigger settings: AutomationSettings state: AutomationState createdAt: string @@ -84,7 +86,7 @@ export interface AutomationData { export interface CreateAutomationRequest { name: string type: AutomationType - trigger: AutomationTrigger + trigger: AutomationTrigger | AutomationSyncTrigger settings: AutomationSettings } diff --git a/frontend/public/icons/crowd-icons.svg b/frontend/public/icons/crowd-icons.svg index 1beaec7db0..dd5718f88e 100644 --- a/frontend/public/icons/crowd-icons.svg +++ b/frontend/public/icons/crowd-icons.svg @@ -30,7 +30,7 @@ - + diff --git a/frontend/public/images/automation/hubspot-paywall.png b/frontend/public/images/automation/hubspot-paywall.png new file mode 100644 index 0000000000..2a445463b4 Binary files /dev/null and b/frontend/public/images/automation/hubspot-paywall.png differ diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index cbc69d8af7..21fc3160dc 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -385,6 +385,8 @@ const en = { new_activity: 'New activity happened in your community', new_member: 'New member joined your community', + member_attributes_match: 'Member attributes match condition(s)', + organization_attributes_match: 'Organization attributes match condition(s)', }, }, diff --git a/frontend/src/integrations/hubspot/components/hubspot-property-map.vue b/frontend/src/integrations/hubspot/components/hubspot-property-map.vue index 95e06f7ab4..dd6b3e413d 100644 --- a/frontend/src/integrations/hubspot/components/hubspot-property-map.vue +++ b/frontend/src/integrations/hubspot/components/hubspot-property-map.vue @@ -10,7 +10,10 @@
-
+
(); diff --git a/frontend/src/integrations/hubspot/components/hubspot-readonly-attr-popover.vue b/frontend/src/integrations/hubspot/components/hubspot-readonly-attr-popover.vue new file mode 100644 index 0000000000..f2fc58ea02 --- /dev/null +++ b/frontend/src/integrations/hubspot/components/hubspot-readonly-attr-popover.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/integrations/hubspot/components/hubspot-settings-drawer.vue b/frontend/src/integrations/hubspot/components/hubspot-settings-drawer.vue index a2d60e460e..6b5846abf0 100644 --- a/frontend/src/integrations/hubspot/components/hubspot-settings-drawer.vue +++ b/frontend/src/integrations/hubspot/components/hubspot-settings-drawer.vue @@ -1,7 +1,7 @@ + + + diff --git a/frontend/src/modules/automation/config/automation-types/hubspot/actions/hubspot-action-contact-list.vue b/frontend/src/modules/automation/config/automation-types/hubspot/actions/hubspot-action-contact-list.vue new file mode 100644 index 0000000000..476e69323d --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/hubspot/actions/hubspot-action-contact-list.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/modules/automation/config/automation-types/hubspot/config.ts b/frontend/src/modules/automation/config/automation-types/hubspot/config.ts new file mode 100644 index 0000000000..3c685cb550 --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/hubspot/config.ts @@ -0,0 +1,117 @@ +import { AutomationTypeConfig } from '@/modules/automation/config/automation-types'; +import { FeatureFlag } from '@/featureFlag'; +import { FilterConfig } from '@/shared/modules/filters/types/FilterConfig'; +import noOfActivities from '@/modules/member/config/filters/noOfActivities/config'; +import activityType from '@/modules/member/config/filters/activityType/config'; +import tags from '@/modules/member/config/filters/tags/config'; +import noOfMembers from '@/modules/organization/config/filters/noOfMembers/config'; +import headcount from '@/modules/organization/config/filters/headcount/config'; +import industry from '@/modules/organization/config/filters/industry/config'; +import seniorityLevel from '@/modules/member/config/filters/seniorityLevel/config'; +import annualRevenue from '@/modules/organization/config/filters/annualRevenue/config'; +import { CrowdIntegrations } from '@/integrations/integrations-config'; +import { + HubspotAutomationTrigger, +} from '@/modules/automation/config/automation-types/hubspot/types/HubspotAutomationTrigger'; +import { HubspotEntity } from '@/integrations/hubspot/types/HubspotEntity'; +import AutomationsHubspotPaywall from './hubspot-paywall.vue'; +import AutomationsHubspotTrigger from './hubspot-trigger.vue'; +import AutomationsHubspotAction from './hubspot-action.vue'; + +export const hubspotMemberFilters: Record = { + noOfActivities, + activityType, + seniorityLevel, + tags, +}; + +export const hubspotOrganizationFilters: Record = { + noOfActivities, + noOfMembers, + headcount, + industry, + annualRevenue, +}; + +export const hubspot: AutomationTypeConfig = { + name: 'HubSpot', + description: 'Send members or organizations to HubSpot', + icon: '/images/integrations/hubspot.png', + plan: 'Scale', + featureFlag: FeatureFlag.flags.hubspot, + emptyScreen: { + title: 'No HubSpot automations yet', + body: 'Send members or organizations to HubSpot based on certain conditions.', + }, + triggerText: 'Define the conditions that will trigger your HubSpot action.', + actionText: 'Define which action will take place in HubSpot based on the defined conditions.', + canCreate(store) { + const hubspot = CrowdIntegrations.getMappedConfig('hubspot', store); + return hubspot.status === 'done' && FeatureFlag.isFlagEnabled(FeatureFlag.flags.hubspot); + }, + disabled(store) { + const hubspot = CrowdIntegrations.getMappedConfig('hubspot', store); + return FeatureFlag.isFlagEnabled(FeatureFlag.flags.hubspot) && hubspot.status !== 'done'; + }, + tooltip(store) { + const hubspot = CrowdIntegrations.getMappedConfig('hubspot', store); + if (FeatureFlag.isFlagEnabled(FeatureFlag.flags.hubspot) && hubspot.status !== 'done') { + return 'Connect with HubSpot via Integrations to enable this automation'; + } + return null; + }, + actionButton() { + const hubspotEnabeled = FeatureFlag.isFlagEnabled(FeatureFlag.flags.hubspot); + if (hubspotEnabeled) { + return null; + } + return { + label: 'Upgrade plan', + action: () => { + window.open('https://cal.com/team/crowddotdev/custom-plan', '_blank'); + }, + }; + }, + paywallComponent() { + const hubspotEnabeled = FeatureFlag.isFlagEnabled(FeatureFlag.flags.hubspot); + if (hubspotEnabeled) { + return null; + } + return AutomationsHubspotPaywall; + }, + settingsMap(settings: any, trigger: string) { + const { operator, list, data } = settings; + + let filters: Record = {}; + if (trigger === HubspotAutomationTrigger.MEMBER_ATTRIBUTE_MATCH) { + filters = hubspotMemberFilters; + } + if (trigger === HubspotAutomationTrigger.ORGANIZATION_ATTRIBUTE_MATCH) { + filters = hubspotOrganizationFilters; + } + + const apiFilterData = list.map((property: string) => { + const config: FilterConfig = filters[property]; + return config.apiFilterRenderer(data[property]); + }).flat(); + return { + ...settings, + filter: { + [operator]: apiFilterData, + }, + }; + }, + enableGuard(automation: any, store: string) { + const hubspot = CrowdIntegrations.getMappedConfig('hubspot', store); + const enabledFor = hubspot.settings?.enabledFor || []; + if (automation.trigger === HubspotAutomationTrigger.MEMBER_ATTRIBUTE_MATCH) { + return enabledFor.includes(HubspotEntity.MEMBERS); + } + if (automation.trigger === HubspotAutomationTrigger.ORGANIZATION_ATTRIBUTE_MATCH) { + return enabledFor.includes(HubspotEntity.ORGANIZATIONS); + } + return true; + }, + actionComponent: AutomationsHubspotAction, + triggerComponent: AutomationsHubspotTrigger, +}; diff --git a/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-action.vue b/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-action.vue new file mode 100644 index 0000000000..feef3ccca4 --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-action.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-paywall.vue b/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-paywall.vue new file mode 100644 index 0000000000..64ef999ed9 --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-paywall.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-trigger.vue b/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-trigger.vue new file mode 100644 index 0000000000..cc7dfb4a4a --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/hubspot/hubspot-trigger.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/src/modules/automation/config/automation-types/hubspot/types/HubspotAutomationAction.ts b/frontend/src/modules/automation/config/automation-types/hubspot/types/HubspotAutomationAction.ts new file mode 100644 index 0000000000..fd51b097fb --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/hubspot/types/HubspotAutomationAction.ts @@ -0,0 +1,4 @@ +export enum HubspotAutomationAction { + ADD_TO_CONTACT_LIST = 'add_to_contact_list', + ADD_TO_COMPANY_LIST = 'add_to_company_list', +} diff --git a/frontend/src/modules/automation/config/automation-types/hubspot/types/HubspotAutomationTrigger.ts b/frontend/src/modules/automation/config/automation-types/hubspot/types/HubspotAutomationTrigger.ts new file mode 100644 index 0000000000..d09f6d96de --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/hubspot/types/HubspotAutomationTrigger.ts @@ -0,0 +1,4 @@ +export enum HubspotAutomationTrigger { + MEMBER_ATTRIBUTE_MATCH = 'member_attributes_match', + ORGANIZATION_ATTRIBUTE_MATCH = 'organization_attributes_match', +} diff --git a/frontend/src/modules/automation/config/automation-types/index.ts b/frontend/src/modules/automation/config/automation-types/index.ts new file mode 100644 index 0000000000..74e7dd7d2b --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/index.ts @@ -0,0 +1,39 @@ +import { webhook } from './webhook/config'; +import { slack } from './slack/config'; +import { hubspot } from './hubspot/config'; + +interface AutomationTypeAction { + label: string; // Text of the action button + action: () => void; // Action triggered on button click +} +interface AutomationTypeEmptyScreen { + title: string; // Title in empty screen + body: string; // Body text in empty screen +} + +export interface AutomationTypeConfig { + name: string; // Name of the automation + description: string; // Description shown under name in dropdown + icon: string; // Icon of the automation type + plan?: string; // Plan name + featureFlag?: string; // Plan name + canCreate: (store: any) => boolean; // method if creation of that automation type is disabled + actionButton?: (store: any) => AutomationTypeAction | null; // Action button to show in dropdown + disabled?: (store: any) => boolean | string; // If dropdown option is disabled + tooltip?: (store: any) => string | null; // Show tooltip + emptyScreen?: AutomationTypeEmptyScreen, // Text for empty screen if there is no automations of that type + triggerText: string; // Description shown below Trigger in automation form + actionText: string; // Description shown below Action in automation form + createButtonText?: string; + actionComponent: any; // Component which handeles form for action + triggerComponent: any; // Component which handeles form for trigger + paywallComponent?: (store: any) => any | null; + settingsMap?: (settings: any, trigger: string) => any; // Mapping automation settings before they are sent to backend + enableGuard?: (automation: any, store: any) => boolean; // Guard protecting automation from enabling +} + +export const automationTypes: Record = { + hubspot, + slack, + webhook, +}; diff --git a/frontend/src/modules/automation/components/filter-options/new-activity-filter-options.vue b/frontend/src/modules/automation/config/automation-types/shared/filter-options/new-activity-filter-options.vue similarity index 100% rename from frontend/src/modules/automation/components/filter-options/new-activity-filter-options.vue rename to frontend/src/modules/automation/config/automation-types/shared/filter-options/new-activity-filter-options.vue diff --git a/frontend/src/modules/automation/components/filter-options/new-member-filter-options.vue b/frontend/src/modules/automation/config/automation-types/shared/filter-options/new-member-filter-options.vue similarity index 100% rename from frontend/src/modules/automation/components/filter-options/new-member-filter-options.vue rename to frontend/src/modules/automation/config/automation-types/shared/filter-options/new-member-filter-options.vue diff --git a/frontend/src/modules/automation/config/automation-types/shared/trigger-member-activity.vue b/frontend/src/modules/automation/config/automation-types/shared/trigger-member-activity.vue new file mode 100644 index 0000000000..2a8b110ead --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/shared/trigger-member-activity.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/modules/automation/config/automation-types/slack/config.ts b/frontend/src/modules/automation/config/automation-types/slack/config.ts new file mode 100644 index 0000000000..9da70b50e1 --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/slack/config.ts @@ -0,0 +1,42 @@ +import { AutomationTypeConfig } from '@/modules/automation/config/automation-types'; +import config from '@/config'; +import { AuthToken } from '@/modules/auth/auth-token'; +import AutomationsSlackAction from './slack-action.vue'; +import AutomationsTriggerMemberActivity from '../shared/trigger-member-activity.vue'; + +export const slack: AutomationTypeConfig = { + name: 'Slack notification', + description: 'Send notifications to your Slack workspace', + icon: 'https://cdn-icons-png.flaticon.com/512/3800/3800024.png', + emptyScreen: { + title: 'No Slack notifications yet', + body: 'Send Slack notifications when a new activity happens, or a new member joins your community', + }, + triggerText: 'Define the event that triggers your Slack notification.', + actionText: 'Receive a notification in your Slack workspace every time the event is triggered.', + createButtonText: 'Add Slack notification', + canCreate(store) { + const tenant = store.getters['auth/currentTenant']; + return !!tenant.settings[0].slackWebHook; + }, + actionButton(store) { + const tenant = store.getters['auth/currentTenant']; + const slackConnected = !!tenant.settings[0].slackWebHook; + if (slackConnected) { + return null; + } + return { + label: 'Install app', + action: () => { + const redirectUrl = `${window.location.protocol}//${window.location.host}${window.location.pathname}?activeTab=automations&success=true`; + const slackConnectUrl = `${config.backendUrl}/tenant/${ + tenant.id + }/automation/slack?redirectUrl=${redirectUrl}&crowdToken=${AuthToken.get()}`; + + window.open(slackConnectUrl, '_self'); + }, + }; + }, + actionComponent: AutomationsSlackAction, + triggerComponent: AutomationsTriggerMemberActivity, +}; diff --git a/frontend/src/modules/automation/components/action/slack-action.vue b/frontend/src/modules/automation/config/automation-types/slack/slack-action.vue similarity index 100% rename from frontend/src/modules/automation/components/action/slack-action.vue rename to frontend/src/modules/automation/config/automation-types/slack/slack-action.vue diff --git a/frontend/src/modules/automation/config/automation-types/webhook/config.ts b/frontend/src/modules/automation/config/automation-types/webhook/config.ts new file mode 100644 index 0000000000..d1eeb3dbfe --- /dev/null +++ b/frontend/src/modules/automation/config/automation-types/webhook/config.ts @@ -0,0 +1,19 @@ +import { AutomationTypeConfig } from '@/modules/automation/config/automation-types'; +import AutomationsWebhookAction from './webhook-action.vue'; +import AutomationsTriggerMemberActivity from '../shared/trigger-member-activity.vue'; + +export const webhook: AutomationTypeConfig = { + name: 'Webhook', + description: 'Send webhook payloads to automate workflows', + icon: '/images/automation/webhook.png', + emptyScreen: { + title: 'No Webhooks yet', + body: 'Create webhook actions when a new activity happens, or a new member joins your community', + }, + triggerText: 'Define the event that triggers your webhook', + actionText: 'Define the endpoint where the webhook payload should be sent to', + createButtonText: 'Add webhook', + canCreate: () => true, + actionComponent: AutomationsWebhookAction, + triggerComponent: AutomationsTriggerMemberActivity, +}; diff --git a/frontend/src/modules/automation/components/action/webhook-action.vue b/frontend/src/modules/automation/config/automation-types/webhook/webhook-action.vue similarity index 92% rename from frontend/src/modules/automation/components/action/webhook-action.vue rename to frontend/src/modules/automation/config/automation-types/webhook/webhook-action.vue index 14eeca4071..26491df36e 100644 --- a/frontend/src/modules/automation/components/action/webhook-action.vue +++ b/frontend/src/modules/automation/config/automation-types/webhook/webhook-action.vue @@ -7,6 +7,7 @@ :validation="$v.url" :error-messages="{ required: 'This field is required', + url: 'This is not a valid url', }" > -
-
-
- Webhook -
-
-
- Webhook -
-

- Send webhook payloads to automate workflows -

-
-
-
-
-
-
- Slack -
-
-
- Slack notification -
-

- Send notifications to your Slack workspace -

- - Install app - +
+
+
+ +
+
+
+ {{ automationType.name }} + + {{ plan(automationType) }} + +
+

+ {{ automationType.description }} +

+ + {{ automationType.actionButton(store).label }} + +
-
+
+
@@ -89,22 +95,16 @@ - @@ -128,26 +128,24 @@ import { storeToRefs } from 'pinia'; import pluralize from 'pluralize'; import AppAutomationForm from '@/modules/automation/components/automation-form.vue'; import AppAutomationListTable from '@/modules/automation/components/list/automation-list-table.vue'; -import config from '@/config'; -import { AuthToken } from '@/modules/auth/auth-token'; import { mapGetters } from '@/shared/vuex/vuex.helpers'; import AppAutomationExecutions from '@/modules/automation/components/automation-executions.vue'; import { FeatureFlag } from '@/featureFlag'; import { getWorkflowMax, showWorkflowLimitDialog } from '@/modules/automation/automation-limit'; +import { useStore } from 'vuex'; +import config from '@/config'; +import { automationTypes } from '../config/automation-types'; + const options = ref([ { label: 'All', value: 'all', }, - { - label: 'Slack notifications', - value: 'slack', - }, - { - label: 'Webhooks', - value: 'webhook', - }, + ...(Object.entries(automationTypes).map(([key, config]) => ({ + label: config.name, + value: key, + }))), ]); const openAutomationForm = ref(false); const automationFormType = ref(null); @@ -162,6 +160,9 @@ const { getAutomations, changeAutomationFilter } = automationStore; const { currentTenant } = mapGetters('auth'); +const store = useStore(); +const fetchIntegrations = () => store.dispatch('integration/doFetch'); + /** * Check if tenant has feature flag enabled */ @@ -183,6 +184,10 @@ const canAddAutomation = () => { // Executions drawer const createAutomation = (type) => { + if (!automationTypes[type].canCreate(store)) { + return; + } + if (!canAddAutomation()) { return; } @@ -198,32 +203,18 @@ const updateAutomation = (automation) => { editAutomation.value = automation; }; -// Slack connect -const slackConnected = computed(() => currentTenant.value?.settings[0].slackWebHook); - -const slackConnectUrl = computed(() => { - const redirectUrl = `${window.location.protocol}//${window.location.host}${window.location.pathname}?activeTab=automations&success=true`; - - return `${config.backendUrl}/tenant/${ - currentTenant.value.id - }/automation/slack?redirectUrl=${redirectUrl}&crowdToken=${AuthToken.get()}`; -}); - -const authenticateSlack = () => { - window.open(slackConnectUrl.value, '_self'); -}; - -const createSlackAutomation = () => { - if (slackConnected.value) { - if (!canAddAutomation()) { - return; +const plan = (type) => { + if (type.plan && type.featureFlag && !FeatureFlag.isFlagEnabled(type.featureFlag)) { + if (config.isCommunityVersion) { + return 'Premium'; } - - createAutomation('slack'); + return type.plan; } + return null; }; onMounted(() => { + fetchIntegrations(); getAutomations(); }); diff --git a/frontend/src/modules/member/config/filters/seniorityLevel/config.ts b/frontend/src/modules/member/config/filters/seniorityLevel/config.ts new file mode 100644 index 0000000000..17ccfbcb7a --- /dev/null +++ b/frontend/src/modules/member/config/filters/seniorityLevel/config.ts @@ -0,0 +1,19 @@ +import { FilterConfigType } from '@/shared/modules/filters/types/FilterConfig'; +import { itemLabelRendererByType } from '@/shared/modules/filters/config/itemLabelRendererByType'; +import { apiFilterRendererByType } from '@/shared/modules/filters/config/apiFilterRendererByType'; +import { StringFilterConfig, StringFilterOptions, StringFilterValue } from '@/shared/modules/filters/types/filterTypes/StringFilterConfig'; + +const seniorityLevel: StringFilterConfig = { + id: 'seniorityLevel', + label: 'Seniority level', + iconClass: 'ri-menu-2-line', + type: FilterConfigType.STRING, + options: {}, + itemLabelRenderer(value: StringFilterValue, options: StringFilterOptions): string { + return itemLabelRendererByType[FilterConfigType.STRING]('Seniority level', value, options); + }, + apiFilterRenderer(value: StringFilterValue): any[] { + return apiFilterRendererByType[FilterConfigType.STRING]('attributes.seniorityLevel.default', value); + }, +}; +export default seniorityLevel; diff --git a/frontend/src/modules/member/member-export-limit.js b/frontend/src/modules/member/member-export-limit.js index 692fc13062..d2e0fa1b82 100644 --- a/frontend/src/modules/member/member-export-limit.js +++ b/frontend/src/modules/member/member-export-limit.js @@ -3,6 +3,7 @@ import { router } from '@/router'; import ConfirmDialog from '@/shared/dialog/confirm-dialog'; const exportMax = { + scale: 'unlimited', enterprise: 'unlimited', growth: 10, essential: 2, @@ -13,6 +14,9 @@ const exportMax = { * @returns maximum number of exports */ export const getExportMax = (plan) => { + if (plan === Plans.values.scale) { + return exportMax.scale; + } if (plan === Plans.values.enterprise) { return exportMax.enterprise; } if ( diff --git a/frontend/src/modules/organization/config/filters/annualRevenue/config.ts b/frontend/src/modules/organization/config/filters/annualRevenue/config.ts new file mode 100644 index 0000000000..f7bd3449d6 --- /dev/null +++ b/frontend/src/modules/organization/config/filters/annualRevenue/config.ts @@ -0,0 +1,24 @@ +import { + NumberFilterConfig, + NumberFilterOptions, + NumberFilterValue, +} from '@/shared/modules/filters/types/filterTypes/NumberFilterConfig'; +import { FilterConfigType } from '@/shared/modules/filters/types/FilterConfig'; +import { itemLabelRendererByType } from '@/shared/modules/filters/config/itemLabelRendererByType'; +import { apiFilterRendererByType } from '@/shared/modules/filters/config/apiFilterRendererByType'; + +const annualRevenue: NumberFilterConfig = { + id: 'annualRevenue', + label: 'Annual revenue', + iconClass: 'ri-money-dollar-circle-line', + type: FilterConfigType.NUMBER, + options: {}, + itemLabelRenderer(value: NumberFilterValue, options: NumberFilterOptions): string { + return itemLabelRendererByType[FilterConfigType.NUMBER]('Annual revenue', value, options); + }, + apiFilterRenderer(value: NumberFilterValue): any[] { + return apiFilterRendererByType[FilterConfigType.NUMBER]('revenueRange', value); + }, +}; + +export default annualRevenue; diff --git a/frontend/src/modules/organization/config/filters/headcount/config.ts b/frontend/src/modules/organization/config/filters/headcount/config.ts index 93533c7163..5e951fd13a 100644 --- a/frontend/src/modules/organization/config/filters/headcount/config.ts +++ b/frontend/src/modules/organization/config/filters/headcount/config.ts @@ -19,7 +19,7 @@ const headcount: SelectFilterConfig = { }, apiFilterRenderer({ value, include }: SelectFilterValue): any[] { const filter = { - size: value, + size: { eq: value }, }; return [ (include ? filter : { not: filter }), diff --git a/frontend/src/shared/modules/filters/components/FilterItem.vue b/frontend/src/shared/modules/filters/components/FilterItem.vue index a64b8a6583..9495aa39ad 100644 --- a/frontend/src/shared/modules/filters/components/FilterItem.vue +++ b/frontend/src/shared/modules/filters/components/FilterItem.vue @@ -12,7 +12,7 @@