From 99457019f795636f56d80d3fc2c7e08055ace938 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 17 Jan 2024 19:07:34 +0200 Subject: [PATCH] feat: Nudge users to become template creators if eligible (#8357) --- .../composables/becomeTemplateCreatorCta.ts | 18 ++++ cypress/e2e/37-become-creator-cta.cy.ts | 32 +++++++ packages/cli/src/Server.ts | 5 + .../cli/src/controllers/cta.controller.ts | 21 +++++ .../workflowStatistics.repository.ts | 24 ++++- packages/cli/src/services/cta.service.ts | 18 ++++ .../cli/test/integration/cta.service.test.ts | 54 +++++++++++ .../shared/db/workflowStatistics.ts | 21 +++++ packages/editor-ui/src/api/ctas.ts | 8 ++ .../BecomeTemplateCreatorCta.vue | 78 +++++++++++++++ .../becomeTemplateCreatorStore.ts | 94 +++++++++++++++++++ .../editor-ui/src/components/MainSidebar.vue | 8 ++ packages/editor-ui/src/constants.ts | 1 + .../src/plugins/i18n/locales/en.json | 6 +- 14 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 cypress/composables/becomeTemplateCreatorCta.ts create mode 100644 cypress/e2e/37-become-creator-cta.cy.ts create mode 100644 packages/cli/src/controllers/cta.controller.ts create mode 100644 packages/cli/src/services/cta.service.ts create mode 100644 packages/cli/test/integration/cta.service.test.ts create mode 100644 packages/cli/test/integration/shared/db/workflowStatistics.ts create mode 100644 packages/editor-ui/src/api/ctas.ts create mode 100644 packages/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue create mode 100644 packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts diff --git a/cypress/composables/becomeTemplateCreatorCta.ts b/cypress/composables/becomeTemplateCreatorCta.ts new file mode 100644 index 0000000000000..55fc985c745bc --- /dev/null +++ b/cypress/composables/becomeTemplateCreatorCta.ts @@ -0,0 +1,18 @@ +//#region Getters + +export const getBecomeTemplateCreatorCta = () => cy.getByTestId('become-template-creator-cta'); + +export const getCloseBecomeTemplateCreatorCtaButton = () => + cy.getByTestId('close-become-template-creator-cta'); + +//#endregion + +//#region Actions + +export const interceptCtaRequestWithResponse = (becomeCreator: boolean) => { + return cy.intercept('GET', `/rest/cta/become-creator`, { + body: becomeCreator, + }); +}; + +//#endregion diff --git a/cypress/e2e/37-become-creator-cta.cy.ts b/cypress/e2e/37-become-creator-cta.cy.ts new file mode 100644 index 0000000000000..931208e5f35d5 --- /dev/null +++ b/cypress/e2e/37-become-creator-cta.cy.ts @@ -0,0 +1,32 @@ +import { + getBecomeTemplateCreatorCta, + getCloseBecomeTemplateCreatorCtaButton, + interceptCtaRequestWithResponse, +} from '../composables/becomeTemplateCreatorCta'; +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; + +const WorkflowsPage = new WorkflowsPageClass(); + +describe('Become creator CTA', () => { + it('should not show the CTA if user is not eligible', () => { + interceptCtaRequestWithResponse(false).as('cta'); + cy.visit(WorkflowsPage.url); + + cy.wait('@cta'); + + getBecomeTemplateCreatorCta().should('not.exist'); + }); + + it('should show the CTA if the user is eligible', () => { + interceptCtaRequestWithResponse(true).as('cta'); + cy.visit(WorkflowsPage.url); + + cy.wait('@cta'); + + getBecomeTemplateCreatorCta().should('be.visible'); + + getCloseBecomeTemplateCreatorCtaButton().click(); + + getBecomeTemplateCreatorCta().should('not.exist'); + }); +}); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 9ddaab99e6436..43c7e60a8a396 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -280,6 +280,11 @@ export class Server extends AbstractServer { controllers.push(MFAController); } + if (!config.getEnv('endpoints.disableUi')) { + const { CtaController } = await import('@/controllers/cta.controller'); + controllers.push(CtaController); + } + controllers.forEach((controller) => registerController(app, controller)); } diff --git a/packages/cli/src/controllers/cta.controller.ts b/packages/cli/src/controllers/cta.controller.ts new file mode 100644 index 0000000000000..9d06ff081223c --- /dev/null +++ b/packages/cli/src/controllers/cta.controller.ts @@ -0,0 +1,21 @@ +import express from 'express'; +import { Authorized, Get, RestController } from '@/decorators'; +import { AuthenticatedRequest } from '@/requests'; +import { CtaService } from '@/services/cta.service'; + +/** + * Controller for Call to Action (CTA) endpoints. CTAs are certain + * messages that are shown to users in the UI. + */ +@Authorized() +@RestController('/cta') +export class CtaController { + constructor(private readonly ctaService: CtaService) {} + + @Get('/become-creator') + async getCta(req: AuthenticatedRequest, res: express.Response) { + const becomeCreator = await this.ctaService.getBecomeCreatorCta(req.user.id); + + res.json(becomeCreator); + } +} diff --git a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts index 5d6a9261da477..b08a175b42834 100644 --- a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -1,8 +1,11 @@ import { Service } from 'typedi'; import { DataSource, QueryFailedError, Repository } from 'typeorm'; import config from '@/config'; -import type { StatisticsNames } from '../entities/WorkflowStatistics'; -import { WorkflowStatistics } from '../entities/WorkflowStatistics'; +import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics'; +import type { User } from '@/databases/entities/User'; +import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; +import { Role } from '@/databases/entities/Role'; type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists'; type StatisticsUpsertResult = StatisticsInsertResult | 'update'; @@ -98,4 +101,21 @@ export class WorkflowStatisticsRepository extends Repository throw error; } } + + async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise { + return await this.createQueryBuilder('workflow_statistics') + .innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId') + .innerJoin( + SharedWorkflow, + 'shared_workflow', + 'shared_workflow.workflowId = workflow_statistics.workflowId', + ) + .innerJoin(Role, 'role', 'role.id = shared_workflow.roleId') + .where('shared_workflow.userId = :userId', { userId }) + .andWhere('workflow.active = :isActive', { isActive: true }) + .andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess }) + .andWhere('workflow_statistics.count >= 5') + .andWhere('role.name = :roleName', { roleName: 'owner' }) + .getCount(); + } } diff --git a/packages/cli/src/services/cta.service.ts b/packages/cli/src/services/cta.service.ts new file mode 100644 index 0000000000000..e5c08a437bc54 --- /dev/null +++ b/packages/cli/src/services/cta.service.ts @@ -0,0 +1,18 @@ +import { Service } from 'typedi'; +import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository'; +import type { User } from '@/databases/entities/User'; + +@Service() +export class CtaService { + constructor(private readonly workflowStatisticsRepository: WorkflowStatisticsRepository) {} + + async getBecomeCreatorCta(userId: User['id']) { + // There need to be at least 3 workflows with at least 5 executions + const numWfsWithOver5ProdExecutions = + await this.workflowStatisticsRepository.queryNumWorkflowsUserHasWithFiveOrMoreProdExecs( + userId, + ); + + return numWfsWithOver5ProdExecutions >= 3; + } +} diff --git a/packages/cli/test/integration/cta.service.test.ts b/packages/cli/test/integration/cta.service.test.ts new file mode 100644 index 0000000000000..27cdc7b60492d --- /dev/null +++ b/packages/cli/test/integration/cta.service.test.ts @@ -0,0 +1,54 @@ +import Container from 'typedi'; +import * as testDb from './shared/testDb'; +import { CtaService } from '@/services/cta.service'; +import { createUser } from './shared/db/users'; +import { createManyWorkflows } from './shared/db/workflows'; +import type { User } from '@/databases/entities/User'; +import { createWorkflowStatisticsItem } from './shared/db/workflowStatistics'; +import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; + +describe('CtaService', () => { + let ctaService: CtaService; + let user: User; + + beforeAll(async () => { + await testDb.init(); + + ctaService = Container.get(CtaService); + user = await createUser(); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getBecomeCreatorCta()', () => { + afterEach(async () => { + await testDb.truncate(['Workflow', 'SharedWorkflow']); + }); + + test.each([ + [false, 0, 0], + [false, 2, 5], + [false, 3, 4], + [true, 3, 5], + ])( + 'should return %p if user has %d active workflows with %d successful production executions', + async (expected, numWorkflows, numExecutions) => { + const workflows = await createManyWorkflows(numWorkflows, { active: true }, user); + + await Promise.all( + workflows.map( + async (workflow) => + await createWorkflowStatisticsItem(workflow.id, { + count: numExecutions, + name: StatisticsNames.productionSuccess, + }), + ), + ); + + expect(await ctaService.getBecomeCreatorCta(user.id)).toBe(expected); + }, + ); + }); +}); diff --git a/packages/cli/test/integration/shared/db/workflowStatistics.ts b/packages/cli/test/integration/shared/db/workflowStatistics.ts new file mode 100644 index 0000000000000..e690cb726afae --- /dev/null +++ b/packages/cli/test/integration/shared/db/workflowStatistics.ts @@ -0,0 +1,21 @@ +import Container from 'typedi'; +import { StatisticsNames, type WorkflowStatistics } from '@/databases/entities/WorkflowStatistics'; +import type { Workflow } from 'n8n-workflow'; +import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository'; + +export async function createWorkflowStatisticsItem( + workflowId: Workflow['id'], + data?: Partial, +) { + const entity = Container.get(WorkflowStatisticsRepository).create({ + count: 0, + latestEvent: new Date().toISOString(), + name: StatisticsNames.manualSuccess, + ...(data ?? {}), + workflowId, + }); + + await Container.get(WorkflowStatisticsRepository).insert(entity); + + return entity; +} diff --git a/packages/editor-ui/src/api/ctas.ts b/packages/editor-ui/src/api/ctas.ts new file mode 100644 index 0000000000000..88a0e7da71405 --- /dev/null +++ b/packages/editor-ui/src/api/ctas.ts @@ -0,0 +1,8 @@ +import type { IRestApiContext } from '@/Interface'; +import { get } from '@/utils/apiUtils'; + +export async function getBecomeCreatorCta(context: IRestApiContext): Promise { + const response = await get(context.baseUrl, '/cta/become-creator'); + + return response; +} diff --git a/packages/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue b/packages/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue new file mode 100644 index 0000000000000..dc9958f4d67f2 --- /dev/null +++ b/packages/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts b/packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts new file mode 100644 index 0000000000000..83785e0faef2d --- /dev/null +++ b/packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts @@ -0,0 +1,94 @@ +import { DateTime } from 'luxon'; +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { STORES } from '@/constants'; +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; +import { useStorage } from '@/composables/useStorage'; +import { useRootStore } from '@/stores/n8nRoot.store'; +import { getBecomeCreatorCta } from '@/api/ctas'; + +const LOCAL_STORAGE_KEY = 'N8N_BECOME_TEMPLATE_CREATOR_CTA_DISMISSED_AT'; +const RESHOW_DISMISSED_AFTER_DAYS = 30; +const POLL_INTERVAL_IN_MS = 15 * 60 * 1000; // 15 minutes + +export const useBecomeTemplateCreatorStore = defineStore(STORES.BECOME_TEMPLATE_CREATOR, () => { + const cloudPlanStore = useCloudPlanStore(); + const rootStore = useRootStore(); + + //#region State + + const dismissedAt = useStorage(LOCAL_STORAGE_KEY); + const ctaMeetsCriteria = ref(false); + const monitorCtasTimer = ref | null>(null); + + //#endregion State + + //#region Computed + + const isDismissed = computed(() => { + return dismissedAt.value ? !hasEnoughTimePassedSinceDismissal(dismissedAt.value) : false; + }); + + const showBecomeCreatorCta = computed(() => { + return ctaMeetsCriteria.value && !cloudPlanStore.userIsTrialing && !isDismissed.value; + }); + + //#endregion Computed + + //#region Actions + + const dismissCta = () => { + dismissedAt.value = DateTime.now().toISO(); + }; + + const fetchBecomeCreatorCta = async () => { + const becomeCreatorCta = await getBecomeCreatorCta(rootStore.getRestApiContext); + + ctaMeetsCriteria.value = becomeCreatorCta; + }; + + const fetchUserCtasIfNeeded = async () => { + if (isDismissed.value || cloudPlanStore.userIsTrialing || ctaMeetsCriteria.value) { + return; + } + + await fetchBecomeCreatorCta(); + }; + + const startMonitoringCta = () => { + if (monitorCtasTimer.value) { + return; + } + + // Initial check after 1s so we don't bombard the API immediately during startup + setTimeout(fetchUserCtasIfNeeded, 1000); + + monitorCtasTimer.value = setInterval(fetchUserCtasIfNeeded, POLL_INTERVAL_IN_MS); + }; + + const stopMonitoringCta = () => { + if (!monitorCtasTimer.value) { + return; + } + + clearInterval(monitorCtasTimer.value); + monitorCtasTimer.value = null; + }; + + //#endregion Actions + + return { + showBecomeCreatorCta, + dismissCta, + startMonitoringCta, + stopMonitoringCta, + }; +}); + +function hasEnoughTimePassedSinceDismissal(dismissedAt: string) { + const reshowAtTime = DateTime.fromISO(dismissedAt).plus({ + days: RESHOW_DISMISSED_AFTER_DAYS, + }); + + return reshowAtTime <= DateTime.now(); +} diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 61f8003f155de..c183635e60dce 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -23,6 +23,7 @@