From de9f3ef2db8809efd0fb7e2b9b70f0dcb14b07ef Mon Sep 17 00:00:00 2001 From: Romeo Balta <7095569+romeobalta@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:31:47 +0100 Subject: [PATCH] feat(editor): Add published workflow count to dynamic banners (no-changelog) (#32619) --- .../@n8n/api-types/src/frontend-settings.ts | 3 + .../__tests__/workflow.repository.test.ts | 18 +++- .../src/repositories/workflow.repository.ts | 6 ++ .../__tests__/frontend.service.test.ts | 84 +++++++++++++++++++ packages/cli/src/services/frontend.service.ts | 43 +++++++++- .../src/api/dynamic-banners.ts | 2 + .../editor-ui/src/__tests__/defaults.ts | 3 + .../shared/banners/banners.store.test.ts | 52 ++++++++++++ .../features/shared/banners/banners.store.ts | 3 + 9 files changed, 212 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 3ca665ad70d..d952a1c3de6 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -116,6 +116,9 @@ export interface FrontendSettings { dynamicBanners: { endpoint: string; enabled: boolean; + filters: { + publishedWorkflowCount: number; + }; }; instanceId: string; telemetry: ITelemetrySettings; diff --git a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts index 0617ed842c1..8e03db9b903 100644 --- a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import { In, type SelectQueryBuilder } from '@n8n/typeorm'; +import { In, IsNull, Not, type SelectQueryBuilder } from '@n8n/typeorm'; import type { Mock, Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -576,6 +576,22 @@ describe('WorkflowRepository', () => { }); }); + describe('getPublishedCount', () => { + it('should count non-archived workflows with an active version', async () => { + const countSpy = vi.spyOn(workflowRepository, 'count').mockResolvedValue(7); + + const result = await workflowRepository.getPublishedCount(); + + expect(result).toBe(7); + expect(countSpy).toHaveBeenCalledWith({ + where: { + activeVersionId: Not(IsNull()), + isArchived: false, + }, + }); + }); + }); + describe('findByCredentialResolverId', () => { it('should use PostgreSQL JSON operator for postgresdb', async () => { const workflows = [{ id: 'wf-1', name: 'Workflow 1' }] as WorkflowEntity[]; diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 27eaefab7ae..0cdd0d56acf 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -100,6 +100,12 @@ export class WorkflowRepository extends Repository { }); } + async getPublishedCount() { + return await this.count({ + where: { activeVersionId: Not(IsNull()), isArchived: false }, + }); + } + async getPublishedPersonalWorkflowsCount(): Promise { return await this.createQueryBuilder('workflow') .innerJoin('workflow.shared', 'shared') diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index cb2581a04f8..7a574260ee1 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -1,5 +1,6 @@ import type { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import type { GlobalConfig, SecurityConfig } from '@n8n/config'; +import type { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { BinaryDataConfig, InstanceSettings } from 'n8n-core'; @@ -43,6 +44,10 @@ describe('FrontendService', () => { whatsNewEndpoint: '', infoUrl: '', }, + dynamicBanners: { + endpoint: 'https://api.n8n.io/api/banners', + enabled: true, + }, personalization: { enabled: false }, defaultLocale: 'en', auth: { cookie: { secure: false } }, @@ -179,6 +184,10 @@ describe('FrontendService', () => { getAiUsageSettings: jest.fn().mockResolvedValue(true), }); + const workflowRepository = mock({ + getPublishedCount: jest.fn().mockResolvedValue(7), + }); + const createMockService = () => { Container.set( CommunityPackagesConfig, @@ -206,6 +215,7 @@ describe('FrontendService', () => { mfaService, ownershipService, aiUsageService, + workflowRepository, ), license, }; @@ -214,9 +224,11 @@ describe('FrontendService', () => { beforeEach(() => { originalEnv = process.env; jest.clearAllMocks(); + globalConfig.diagnostics.enabled = false; }); afterEach(() => { + jest.useRealTimers(); process.env = originalEnv; }); @@ -232,6 +244,78 @@ describe('FrontendService', () => { ); }); + it('should cache dynamic banner filters for 30 seconds', async () => { + jest.useFakeTimers({ now: new Date('2026-01-01T00:00:00.000Z') }); + globalConfig.diagnostics.enabled = true; + globalConfig.diagnostics.frontendConfig = 'key;http://localhost'; + workflowRepository.getPublishedCount.mockResolvedValueOnce(7).mockResolvedValueOnce(8); + + const { service } = createMockService(); + const settings = await service.getSettings(); + + expect(settings.dynamicBanners.filters).toEqual({ + publishedWorkflowCount: 7, + }); + expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(29_999); + const cachedSettings = await service.getSettings(); + + expect(cachedSettings.dynamicBanners.filters).toEqual({ + publishedWorkflowCount: 7, + }); + expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1); + const refreshedSettings = await service.getSettings(); + + expect(refreshedSettings.dynamicBanners.filters).toEqual({ + publishedWorkflowCount: 8, + }); + expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(2); + }); + + it('should fall back when dynamic banner filters cannot be loaded', async () => { + globalConfig.diagnostics.enabled = true; + globalConfig.diagnostics.frontendConfig = 'key;http://localhost'; + workflowRepository.getPublishedCount.mockRejectedValueOnce(new Error('database unavailable')); + + const { service } = createMockService(); + const settings = await service.getSettings(); + + expect(settings.dynamicBanners.filters).toEqual({ + publishedWorkflowCount: 0, + }); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to fetch published workflow count for dynamic banners', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + + it('should fall back to the last published workflow count when refresh fails', async () => { + jest.useFakeTimers({ now: new Date('2026-01-01T00:00:00.000Z') }); + globalConfig.diagnostics.enabled = true; + globalConfig.diagnostics.frontendConfig = 'key;http://localhost'; + workflowRepository.getPublishedCount + .mockResolvedValueOnce(7) + .mockRejectedValueOnce(new Error('database unavailable')); + + const { service } = createMockService(); + await service.getSettings(); + jest.advanceTimersByTime(30_000); + + const settings = await service.getSettings(); + + expect(settings.dynamicBanners.filters).toEqual({ + publishedWorkflowCount: 7, + }); + expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to fetch published workflow count for dynamic banners', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + it('should surface logStreaming.managedByEnv from instanceSettingsLoader config', async () => { globalConfig.instanceSettingsLoader = { logStreamingManagedByEnv: true, diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index d53d3449741..b6afa7ba5f0 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -1,7 +1,8 @@ import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types'; import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import { GlobalConfig, SecurityConfig } from '@n8n/config'; -import { LICENSE_FEATURES, LICENSE_QUOTAS } from '@n8n/constants'; +import { LICENSE_FEATURES, LICENSE_QUOTAS, Time } from '@n8n/constants'; +import { WorkflowRepository } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { createWriteStream } from 'fs'; import { mkdir } from 'fs/promises'; @@ -33,6 +34,8 @@ import { import { AiUsageService } from './ai-usage.service'; import { UrlService } from './url.service'; +const DYNAMIC_BANNER_FILTERS_CACHE_TTL = 30 * Time.seconds.toMilliseconds; + /** * IMPORTANT: Only add settings that are absolutely necessary for non-authenticated pages */ @@ -111,6 +114,10 @@ export class FrontendService { private communityPackagesService?: CommunityPackagesService; + private publishedWorkflowCountCache?: { value: number; expiresAt: number }; + + private publishedWorkflowCountRequest?: Promise; + constructor( private readonly globalConfig: GlobalConfig, private readonly logger: Logger, @@ -129,6 +136,7 @@ export class FrontendService { private readonly mfaService: MfaService, private readonly ownershipService: OwnershipService, private readonly aiUsageService: AiUsageService, + private readonly workflowRepository: WorkflowRepository, ) { loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); void this.generateTypes(); @@ -238,6 +246,9 @@ export class FrontendService { dynamicBanners: { endpoint: this.globalConfig.dynamicBanners.endpoint, enabled: this.globalConfig.dynamicBanners.enabled && this.globalConfig.diagnostics.enabled, + filters: { + publishedWorkflowCount: 0, + }, }, instanceId: this.instanceSettings.instanceId, telemetry: telemetrySettings, @@ -439,6 +450,8 @@ export class FrontendService { oauth2: `${instanceBaseUrl}/${restEndpoint}/oauth2-credential/callback`, }; this.settings.jwksUri = `${instanceBaseUrl}/${restEndpoint}/.well-known/jwks.json`; + this.settings.dynamicBanners.filters.publishedWorkflowCount = + await this.getPublishedWorkflowCountForDynamicBanners(); // refresh user management status Object.assign(this.settings.userManagement, { @@ -594,6 +607,34 @@ export class FrontendService { return this.settings; } + private async getPublishedWorkflowCountForDynamicBanners(): Promise { + if (!this.settings.dynamicBanners.enabled) return 0; + + const now = Date.now(); + if (this.publishedWorkflowCountCache && this.publishedWorkflowCountCache.expiresAt > now) { + return this.publishedWorkflowCountCache.value; + } + + try { + this.publishedWorkflowCountRequest ??= this.workflowRepository + .getPublishedCount() + .finally(() => { + this.publishedWorkflowCountRequest = undefined; + }); + + const value = await this.publishedWorkflowCountRequest; + this.publishedWorkflowCountCache = { + value, + expiresAt: Date.now() + DYNAMIC_BANNER_FILTERS_CACHE_TTL, + }; + + return value; + } catch (error) { + this.logger.warn('Failed to fetch published workflow count for dynamic banners', { error }); + return this.publishedWorkflowCountCache?.value ?? 0; + } + } + /** * Only add settings that are absolutely necessary for non-authenticated pages * @returns Public settings for unauthenticated users diff --git a/packages/frontend/@n8n/rest-api-client/src/api/dynamic-banners.ts b/packages/frontend/@n8n/rest-api-client/src/api/dynamic-banners.ts index 451b4ad5d0f..cbf942569f2 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/dynamic-banners.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/dynamic-banners.ts @@ -17,9 +17,11 @@ type DynamicBannerFilters = { deploymentType: string; planName?: string; instanceId: string; + userId?: string; userCreatedAt?: string; isOwner?: boolean; role?: Role; + publishedWorkflowCount?: number; }; export async function getDynamicBanners( diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index ec16530bd23..9f663a6158d 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -188,5 +188,8 @@ export const defaultSettings: FrontendSettings = { dynamicBanners: { endpoint: 'https://api.n8n.io/api/banners', enabled: true, + filters: { + publishedWorkflowCount: 0, + }, }, }; diff --git a/packages/frontend/editor-ui/src/features/shared/banners/banners.store.test.ts b/packages/frontend/editor-ui/src/features/shared/banners/banners.store.test.ts index 125cdccfd9f..61d2663b9a7 100644 --- a/packages/frontend/editor-ui/src/features/shared/banners/banners.store.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/banners/banners.store.test.ts @@ -2,21 +2,27 @@ import { createPinia, setActivePinia } from 'pinia'; import { useBannersStore } from '@/features/shared/banners/banners.store'; import { useSettingsStore } from '@/app/stores/settings.store'; import * as dynamicBannersApi from '@n8n/rest-api-client/api/dynamic-banners'; +import { useUsersStore } from '@/features/settings/users/users.store'; let bannersStore: ReturnType; let settingsStore: ReturnType; +let usersStore: ReturnType; describe('Banners store', () => { beforeEach(() => { setActivePinia(createPinia()); bannersStore = useBannersStore(); settingsStore = useSettingsStore(); + usersStore = useUsersStore(); // Set up settings store with required configuration settingsStore.settings = { dynamicBanners: { endpoint: 'https://test.endpoint.com', enabled: false, + filters: { + publishedWorkflowCount: 0, + }, }, banners: { dismissed: [], @@ -24,6 +30,10 @@ describe('Banners store', () => { } as unknown as typeof settingsStore.settings; }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should add non-production license banner to stack based on enterprise settings', () => { bannersStore.loadStaticBanners({ banners: ['NON_PRODUCTION_LICENSE'], @@ -83,6 +93,9 @@ describe('Banners store', () => { dynamicBanners: { endpoint: 'https://test.endpoint.com', enabled: true, + filters: { + publishedWorkflowCount: 2, + }, }, banners: { dismissed: ['dynamic-banner-2'], @@ -100,4 +113,43 @@ describe('Banners store', () => { expect(freshBannersStore.bannerStack).not.toContain('dynamic-banner-2'); }); + + it('should send dynamic banner filters as flat query params', async () => { + const getDynamicBannersSpy = vi + .spyOn(dynamicBannersApi, 'getDynamicBanners') + .mockResolvedValue([]); + + settingsStore.settings = { + versionCli: '1.2.3', + deployment: { type: 'cloud' }, + instanceId: 'instance-id', + license: { planName: 'Pro' }, + dynamicBanners: { + endpoint: 'https://test.endpoint.com', + enabled: true, + filters: { + publishedWorkflowCount: 4, + }, + }, + banners: { + dismissed: [], + }, + } as unknown as typeof settingsStore.settings; + usersStore.addUsers([{ id: 'current-user-id' }]); + usersStore.currentUserId = 'current-user-id'; + + await bannersStore.loadDynamicBanners(); + + expect(getDynamicBannersSpy).toHaveBeenCalledWith( + 'https://test.endpoint.com', + expect.objectContaining({ + version: '1.2.3', + deploymentType: 'cloud', + instanceId: 'instance-id', + planName: 'Pro', + userId: 'current-user-id', + publishedWorkflowCount: 4, + }), + ); + }); }); diff --git a/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts b/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts index e025c9ef7ad..6357f500e77 100644 --- a/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts +++ b/packages/frontend/editor-ui/src/features/shared/banners/banners.store.ts @@ -48,9 +48,12 @@ export const useBannersStore = defineStore(STORES.BANNERS, () => { deploymentType, instanceId: settingsStore.settings.instanceId, planName: settingsStore.settings.license?.planName, + userId: usersStore.currentUser?.id, userCreatedAt: usersStore.currentUser?.createdAt, isOwner: usersStore.currentUser?.isOwner, role: usersStore.currentUser?.role, + publishedWorkflowCount: + settingsStore.settings.dynamicBanners.filters.publishedWorkflowCount, }) ).map((item) => ({ ...item,