From c59040e1dceb7622f9ffeb740586eccff365503a Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 17 Jan 2024 11:15:49 +0200 Subject: [PATCH 01/10] feat: Nudge users to become template creators if eligible --- .../composables/becomeTemplateCreatorCta.ts | 20 ++++ cypress/e2e/37-become-creator-cta.cy.ts | 34 +++++++ packages/cli/src/Server.ts | 5 + .../cli/src/controllers/cta.controller.ts | 23 +++++ .../workflowStatistics.repository.ts | 23 ++++- packages/cli/src/services/cta.service.ts | 20 ++++ .../cli/test/integration/cta.service.test.ts | 53 +++++++++++ .../shared/db/workflowStatistics.ts | 21 +++++ packages/editor-ui/src/api/ctas.ts | 12 +++ .../BecomeTemplateCreatorCta.vue | 78 +++++++++++++++ .../becomeTemplateCreatorStore.ts | 94 +++++++++++++++++++ .../editor-ui/src/components/MainSidebar.vue | 9 ++ packages/editor-ui/src/constants.ts | 1 + .../src/plugins/i18n/locales/en.json | 6 +- 14 files changed, 396 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..cd36519eeb1cb --- /dev/null +++ b/cypress/composables/becomeTemplateCreatorCta.ts @@ -0,0 +1,20 @@ +//#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`, { + 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..7bb35a7be070c --- /dev/null +++ b/cypress/e2e/37-become-creator-cta.cy.ts @@ -0,0 +1,34 @@ +import { + getBecomeTemplateCreatorCta, + getCloseBecomeTemplateCreatorCtaButton, + interceptCtaRequestWithResponse, +} from '../composables/becomeTemplateCreatorCta'; +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; + +const WorkflowsPage = new WorkflowsPageClass(); + +describe('Become creator CTA', () => { + beforeEach(() => {}); + + 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', () => { + 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 cb11e29c5c04c..ffa4b3e1a06f5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -279,6 +279,11 @@ export class Server extends AbstractServer { controllers.push(MFAController); } + if (this.frontendService) { + 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..a48f2587cd97f --- /dev/null +++ b/packages/cli/src/controllers/cta.controller.ts @@ -0,0 +1,23 @@ +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 + * notifications/messages that are shown to users in the UI. + */ +@Authorized() +@RestController('/cta') +export class CtaController { + constructor(private readonly ctaService: CtaService) {} + + @Get('/') + 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..cc9d743f7b177 100644 --- a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -1,8 +1,8 @@ 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'; type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists'; type StatisticsUpsertResult = StatisticsInsertResult | 'update'; @@ -98,4 +98,23 @@ export class WorkflowStatisticsRepository extends Repository throw error; } } + + async queryNumWorkflowsUserHasWithOver5ProdExecs(userId: User['id']): Promise { + const numWorkflows = await this.createQueryBuilder('workflow_statistics') + .innerJoin('workflow_entity', 'workflow', 'workflow.id = workflow_statistics.workflowId') + .innerJoin( + 'shared_workflow', + '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(); + + return numWorkflows; + } } diff --git a/packages/cli/src/services/cta.service.ts b/packages/cli/src/services/cta.service.ts new file mode 100644 index 0000000000000..8e423c30023b0 --- /dev/null +++ b/packages/cli/src/services/cta.service.ts @@ -0,0 +1,20 @@ +import { Service } from 'typedi'; +import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository'; +import type { User } from '@/databases/entities/User'; + +export type UserCtas = { + becomeCreator: boolean; +}; + +@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.queryNumWorkflowsUserHasWithOver5ProdExecs(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..6c9a7a7ebf140 --- /dev/null +++ b/packages/cli/test/integration/cta.service.test.ts @@ -0,0 +1,53 @@ +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 executions', + async (expected, numWorkflows, numExecutions) => { + const workflows = await createManyWorkflows(numWorkflows, { active: true }, user); + + await Promise.all( + workflows.map(async (workflow) => + 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..e0852e55699bf --- /dev/null +++ b/packages/editor-ui/src/api/ctas.ts @@ -0,0 +1,12 @@ +import type { IRestApiContext } from '@/Interface'; +import { get } from '@/utils/apiUtils'; + +export type UserCtas = { + becomeCreator: boolean; +}; + +export async function getUserCtas(context: IRestApiContext): Promise { + const response = await get(context.baseUrl, '/cta'); + + 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..5467e4b6871cf --- /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..e9cfc00f248d0 --- /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 { getUserCtas } from '@/api/ctas'; + +const LOCAL_STORAGE_KEY = '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 fetchUserCtas = async () => { + const userCtas = await getUserCtas(rootStore.getRestApiContext); + + ctaMeetsCriteria.value = userCtas.becomeCreator; + }; + + const fetchUserCtasIfNeeded = async () => { + if (isDismissed.value || cloudPlanStore.userIsTrialing || ctaMeetsCriteria.value) { + return; + } + + await fetchUserCtas(); + }; + + 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..df75f595cd4c8 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -23,6 +23,7 @@ @@ -66,13 +68,11 @@ const store = useBecomeTemplateCreatorStore(); width: var(--spacing-2xs); height: var(--spacing-2xs); border: none; - color: #909398; + color: var(--color-text-light); background-color: transparent; } -.becomeButtonLink { - display: flex; - flex-direction: column; +.becomeCreatorButton { margin: var(--spacing-s); } diff --git a/packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts b/packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts index d73625e7b3d89..83785e0faef2d 100644 --- a/packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts +++ b/packages/editor-ui/src/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore.ts @@ -7,7 +7,7 @@ import { useStorage } from '@/composables/useStorage'; import { useRootStore } from '@/stores/n8nRoot.store'; import { getBecomeCreatorCta } from '@/api/ctas'; -const LOCAL_STORAGE_KEY = 'BECOME_TEMPLATE_CREATOR_CTA_DISMISSED_AT'; +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 diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index df75f595cd4c8..c183635e60dce 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -637,4 +637,3 @@ export default defineComponent({ } } -@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore From 1d9896991d434e51d220e98890b48d2a228669b2 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:21:57 +0200 Subject: [PATCH 08/10] test: Fix e2e tests --- cypress/composables/becomeTemplateCreatorCta.ts | 6 ++---- cypress/e2e/37-become-creator-cta.cy.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cypress/composables/becomeTemplateCreatorCta.ts b/cypress/composables/becomeTemplateCreatorCta.ts index cd36519eeb1cb..55fc985c745bc 100644 --- a/cypress/composables/becomeTemplateCreatorCta.ts +++ b/cypress/composables/becomeTemplateCreatorCta.ts @@ -10,10 +10,8 @@ export const getCloseBecomeTemplateCreatorCtaButton = () => //#region Actions export const interceptCtaRequestWithResponse = (becomeCreator: boolean) => { - return cy.intercept('GET', `/rest/cta`, { - body: { - becomeCreator, - }, + return cy.intercept('GET', `/rest/cta/become-creator`, { + body: becomeCreator, }); }; diff --git a/cypress/e2e/37-become-creator-cta.cy.ts b/cypress/e2e/37-become-creator-cta.cy.ts index b356b31a80cac..931208e5f35d5 100644 --- a/cypress/e2e/37-become-creator-cta.cy.ts +++ b/cypress/e2e/37-become-creator-cta.cy.ts @@ -17,7 +17,7 @@ describe('Become creator CTA', () => { getBecomeTemplateCreatorCta().should('not.exist'); }); - it('should show the CTA', () => { + it('should show the CTA if the user is eligible', () => { interceptCtaRequestWithResponse(true).as('cta'); cy.visit(WorkflowsPage.url); From 12571134a466cd4ad0b28ffc2336ce868f6532a3 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:23:09 +0200 Subject: [PATCH 09/10] refactor: Rename function name --- .../databases/repositories/workflowStatistics.repository.ts | 2 +- packages/cli/src/services/cta.service.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts index c67c12baca660..7a621a92232b7 100644 --- a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -102,7 +102,7 @@ export class WorkflowStatisticsRepository extends Repository } } - async queryNumWorkflowsUserHasWith5OrMoreProdExecs(userId: User['id']): Promise { + async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise { const numWorkflows = await this.createQueryBuilder('workflow_statistics') .innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId') .innerJoin( diff --git a/packages/cli/src/services/cta.service.ts b/packages/cli/src/services/cta.service.ts index 69d1854da9661..e5c08a437bc54 100644 --- a/packages/cli/src/services/cta.service.ts +++ b/packages/cli/src/services/cta.service.ts @@ -9,7 +9,9 @@ export class CtaService { async getBecomeCreatorCta(userId: User['id']) { // There need to be at least 3 workflows with at least 5 executions const numWfsWithOver5ProdExecutions = - await this.workflowStatisticsRepository.queryNumWorkflowsUserHasWith5OrMoreProdExecs(userId); + await this.workflowStatisticsRepository.queryNumWorkflowsUserHasWithFiveOrMoreProdExecs( + userId, + ); return numWfsWithOver5ProdExecutions >= 3; } From 4242cf62d5312bf4a4150d83d981f76deac0e6b4 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:30:58 +0200 Subject: [PATCH 10/10] fix: Fix lint errors --- .../repositories/workflowStatistics.repository.ts | 4 +--- packages/cli/test/integration/cta.service.test.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts index 7a621a92232b7..b08a175b42834 100644 --- a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -103,7 +103,7 @@ export class WorkflowStatisticsRepository extends Repository } async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise { - const numWorkflows = await this.createQueryBuilder('workflow_statistics') + return await this.createQueryBuilder('workflow_statistics') .innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId') .innerJoin( SharedWorkflow, @@ -117,7 +117,5 @@ export class WorkflowStatisticsRepository extends Repository .andWhere('workflow_statistics.count >= 5') .andWhere('role.name = :roleName', { roleName: 'owner' }) .getCount(); - - return numWorkflows; } } diff --git a/packages/cli/test/integration/cta.service.test.ts b/packages/cli/test/integration/cta.service.test.ts index ab10e35f829c1..27cdc7b60492d 100644 --- a/packages/cli/test/integration/cta.service.test.ts +++ b/packages/cli/test/integration/cta.service.test.ts @@ -38,11 +38,12 @@ describe('CtaService', () => { const workflows = await createManyWorkflows(numWorkflows, { active: true }, user); await Promise.all( - workflows.map(async (workflow) => - createWorkflowStatisticsItem(workflow.id, { - count: numExecutions, - name: StatisticsNames.productionSuccess, - }), + workflows.map( + async (workflow) => + await createWorkflowStatisticsItem(workflow.id, { + count: numExecutions, + name: StatisticsNames.productionSuccess, + }), ), );