From 9bcad5ae2d63cdff0218636a845a1c7556dbb957 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Tue, 4 Nov 2025 13:15:24 +0000 Subject: [PATCH 01/50] feat: Add support for mysql / mariadb (#21525) --- .../services/__tests__/import.service.test.ts | 24 ------------ packages/cli/src/services/import.service.ts | 9 ----- .../__tests__/validate-database-type.test.ts | 37 ++++++++++++++++++- .../cli/src/utils/validate-database-type.ts | 37 +++++++++++++++---- 4 files changed, 65 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/services/__tests__/import.service.test.ts b/packages/cli/src/services/__tests__/import.service.test.ts index 649fbfe1a9c..9678076110a 100644 --- a/packages/cli/src/services/__tests__/import.service.test.ts +++ b/packages/cli/src/services/__tests__/import.service.test.ts @@ -681,18 +681,6 @@ describe('ImportService', () => { ); }); - it('should throw error when migration IDs do not match', async () => { - const migrationsContent = '{"id":"001","timestamp":"1000","name":"TestMigration"}'; - const dbMigrations = [{ id: '002', timestamp: '1000', name: 'TestMigration' }]; - - jest.mocked(readFile).mockResolvedValue(migrationsContent); - jest.mocked(mockDataSource.query).mockResolvedValue(dbMigrations); - - await expect(importService.validateMigrations('/test/input')).rejects.toThrow( - 'Migration ID mismatch. Import data: TestMigration (id: 001) does not match target database TestMigration (id: 002). Cannot import data from different migration states.', - ); - }); - it('should pass validation when migrations match exactly', async () => { const migrationsContent = '{"id":"1","timestamp":"1000","name":"TestMigration"}'; const dbMigrations = [{ id: '1', timestamp: '1000', name: 'TestMigration' }]; @@ -703,18 +691,6 @@ describe('ImportService', () => { await expect(importService.validateMigrations('/test/input')).resolves.not.toThrow(); }); - it('should throw error when migration IDs have different formats', async () => { - const migrationsContent = '{"id":"001","timestamp":"1000","name":"TestMigration"}'; - const dbMigrations = [{ id: '1', timestamp: '1000', name: 'TestMigration' }]; - - jest.mocked(readFile).mockResolvedValue(migrationsContent); - jest.mocked(mockDataSource.query).mockResolvedValue(dbMigrations); - - await expect(importService.validateMigrations('/test/input')).rejects.toThrow( - 'Migration ID mismatch. Import data: TestMigration (id: 001) does not match target database TestMigration (id: 1). Cannot import data from different migration states.', - ); - }); - it('should handle multiple migrations and find the latest one', async () => { const migrationsContent = '{"id":"2","timestamp":"2000","name":"LatestMigration"}'; const dbMigrations = [ diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index a24da08fcbd..8f337763a6d 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -566,8 +566,6 @@ export class ImportService { const dbTimestamp = parseInt(String(latestDbMigration.timestamp || '0')); const importName = latestImportMigration.name; const dbName = latestDbMigration.name; - const importId = latestImportMigration.id; - const dbId = latestDbMigration.id; // Check timestamp match if (importTimestamp !== dbTimestamp) { @@ -583,13 +581,6 @@ export class ImportService { ); } - // Check ID match (if both have IDs) - if (importId && dbId && importId !== dbId) { - throw new Error( - `Migration ID mismatch. Import data: ${String(importName)} (id: ${String(importId)}) does not match target database ${String(dbName)} (id: ${String(dbId)}). Cannot import data from different migration states.`, - ); - } - this.logger.info( '✅ Migration validation passed - import data matches target database migration state', ); diff --git a/packages/cli/src/utils/__tests__/validate-database-type.test.ts b/packages/cli/src/utils/__tests__/validate-database-type.test.ts index 863080aa19f..eb954c6b3b4 100644 --- a/packages/cli/src/utils/__tests__/validate-database-type.test.ts +++ b/packages/cli/src/utils/__tests__/validate-database-type.test.ts @@ -1,14 +1,47 @@ -import { validateDbTypeForExportEntities } from '../validate-database-type'; +import { + supportedTypesForExport, + supportedTypesForImport, + validateDbTypeForExportEntities, + validateDbTypeForImportEntities, +} from '../validate-database-type'; describe('validateDbTypeForExportEntities', () => { it('should throw an error if the database type is not supported', () => { expect(() => validateDbTypeForExportEntities('invalid')).toThrow( - 'Unsupported database type: invalid. Supported types: sqlite, postgres', + 'Unsupported database type: invalid. Supported types: ' + supportedTypesForExport.join(', '), ); }); it('should not throw an error if the database type is supported', () => { expect(() => validateDbTypeForExportEntities('sqlite')).not.toThrow(); expect(() => validateDbTypeForExportEntities('postgres')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('mysql')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('mariadb')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('mysqldb')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('sqlite-pooled')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('sqlite-memory')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('postgresql')).not.toThrow(); + }); +}); + +describe('validateDbTypeForImportEntities', () => { + it('should throw an error if the database type is not supported', () => { + expect(() => validateDbTypeForImportEntities('invalid')).toThrow( + 'Unsupported database type: invalid. Supported types: ' + supportedTypesForImport.join(', '), + ); + }); + + it('should throw an error for MySQL/MariaDB (not supported for imports)', () => { + expect(() => validateDbTypeForImportEntities('mysql')).toThrow(); + expect(() => validateDbTypeForImportEntities('mariadb')).toThrow(); + expect(() => validateDbTypeForImportEntities('mysqldb')).toThrow(); + }); + + it('should not throw an error if the database type is supported', () => { + expect(() => validateDbTypeForImportEntities('sqlite')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('postgres')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('sqlite-pooled')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('sqlite-memory')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('postgresql')).not.toThrow(); }); }); diff --git a/packages/cli/src/utils/validate-database-type.ts b/packages/cli/src/utils/validate-database-type.ts index 10e78988795..fdc96ed2abd 100644 --- a/packages/cli/src/utils/validate-database-type.ts +++ b/packages/cli/src/utils/validate-database-type.ts @@ -1,11 +1,34 @@ +export const supportedTypesForExport = [ + 'sqlite', + 'sqlite-pooled', + 'sqlite-memory', + 'postgres', + 'postgresql', + 'mysql', + 'mariadb', + 'mysqldb', +]; + +export const supportedTypesForImport = [ + 'sqlite', + 'sqlite-pooled', + 'sqlite-memory', + 'postgres', + 'postgresql', +]; + export function validateDbTypeForExportEntities(dbType: string) { - if ( - !['sqlite', 'sqlite-pooled', 'sqlite-memory', 'postgres', 'postgresql'].includes( - dbType.toLowerCase(), - ) - ) { - throw new Error(`Unsupported database type: ${dbType}. Supported types: sqlite, postgres`); + if (!supportedTypesForExport.includes(dbType.toLowerCase())) { + throw new Error( + `Unsupported database type: ${dbType}. Supported types: ${supportedTypesForExport.join(', ')}`, + ); } } -export const validateDbTypeForImportEntities = validateDbTypeForExportEntities; +export function validateDbTypeForImportEntities(dbType: string) { + if (!supportedTypesForImport.includes(dbType.toLowerCase())) { + throw new Error( + `Unsupported database type: ${dbType}. Supported types: ${supportedTypesForImport.join(', ')}`, + ); + } +} From 4dc58aacf851ab41039a12f8c96eacbe57b6b2cb Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 4 Nov 2025 16:17:57 +0100 Subject: [PATCH 02/50] fix(core): Insights use time aware range when end date is today, and start of day for past ranges (#21540) --- .../__tests__/insights.controller.test.ts | 22 +- .../insights.service.integration.test.ts | 8 +- .../insights-by-period-query.helper.test.ts | 260 +++++++++++------- .../insights-by-period-query.helper.ts | 90 +++--- .../modules/insights/insights.controller.ts | 2 +- .../components/InsightsDashboard.test.ts | 8 +- .../insights/components/InsightsDashboard.vue | 2 +- .../components/InsightsDataRangePicker.vue | 2 +- .../execution/insights/insights.utils.ts | 12 +- 9 files changed, 245 insertions(+), 161 deletions(-) diff --git a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts index 6c6f9200120..dcc7a116e6c 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts @@ -28,7 +28,7 @@ afterAll(async () => { describe('InsightsController', () => { const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository); let controller: InsightsController; - const sixDaysAgo = DateTime.now().minus({ days: 6 }).toJSDate(); + const sevenDaysAgo = DateTime.now().minus({ days: 7 }).toJSDate(); const today = DateTime.now().toJSDate(); const licenseState = mock(); @@ -62,7 +62,7 @@ describe('InsightsController', () => { const callArgs = insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ @@ -102,7 +102,7 @@ describe('InsightsController', () => { const callArgs = insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ @@ -146,7 +146,7 @@ describe('InsightsController', () => { const callArgs = insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ @@ -399,7 +399,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByWorkflow.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ count: 0, data: [] }); @@ -433,7 +433,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByWorkflow.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ count: 3, data: mockRows }); @@ -671,7 +671,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual([]); @@ -694,7 +694,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); @@ -740,7 +740,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); @@ -887,7 +887,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); @@ -912,7 +912,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts index d43b908ff36..4a9be88cbf8 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts @@ -375,7 +375,7 @@ describe('InsightsService (Integration)', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 1 }), + periodStart: now.minus({ days: 2 }), }); // Out of date range insight (should not be included) @@ -553,7 +553,7 @@ describe('InsightsService (Integration)', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 14 }).startOf('day'), + periodStart: now.minus({ days: 14 }).endOf('day'), }); // Out of date range insight (should not be included) @@ -714,7 +714,7 @@ describe('InsightsService (Integration)', () => { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 14 }).startOf('day'), + periodStart: now.minus({ days: 14 }).endOf('day'), }); // Out of date range insight (should not be included) @@ -872,7 +872,7 @@ describe('InsightsService (Integration)', () => { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 14 }).startOf('day'), + periodStart: now.minus({ days: 14 }).endOf('day'), }); // Out of date range insight (should not be included) diff --git a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts index 3d600ab6e48..11150e4f5cf 100644 --- a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts @@ -12,18 +12,40 @@ function expectLastXDaysDateRangeQuery(params: { const { result, dbType, prevStartDateOffset: prev, startDateOffset: start } = params; if (dbType === 'sqlite') { - expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`); - expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`); + if (prev === 0) { + expect(result).toContain("datetime('now') AS prev_start_date"); + } else { + expect(result).toContain(`datetime('now', '-${prev} days') AS prev_start_date`); + } + if (start === 0) { + expect(result).toContain("datetime('now') AS start_date"); + } else { + expect(result).toContain(`datetime('now', '-${start} days') AS start_date`); + } expect(result).toContain("datetime('now') AS end_date"); } else if (dbType === 'postgresdb') { - expect(result).toContain( - `DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`, - ); - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`); + if (prev === 0) { + expect(result).toContain('NOW() AS prev_start_date'); + } else { + expect(result).toContain(`NOW() - INTERVAL '${prev} days' AS prev_start_date`); + } + if (start === 0) { + expect(result).toContain('NOW() AS start_date'); + } else { + expect(result).toContain(`NOW() - INTERVAL '${start} days' AS start_date`); + } expect(result).toContain('NOW() AS end_date'); } else { - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`); - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`); + if (prev === 0) { + expect(result).toContain('NOW() AS prev_start_date'); + } else { + expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${prev} DAY) AS prev_start_date`); + } + if (start === 0) { + expect(result).toContain('NOW() AS start_date'); + } else { + expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${start} DAY) AS start_date`); + } expect(result).toContain('NOW() AS end_date'); } } @@ -46,29 +68,17 @@ function expectStartOfDayDateRangeQuery(params: { if (dbType === 'sqlite') { expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`); expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`); - if (end !== 0) { - expect(result).toContain(`datetime('now', '-${end} days', 'start of day') AS end_date`); - } else { - expect(result).toContain("datetime('now', 'start of day') AS end_date"); - } + expect(result).toContain(`datetime('now', '-${end} days', 'start of day') AS end_date`); } else if (dbType === 'postgresdb') { expect(result).toContain( `DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`, ); expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`); - if (end !== 0) { - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${end} days') AS end_date`); - } else { - expect(result).toContain("DATE_TRUNC('day', NOW()) AS end_date"); - } + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${end} days') AS end_date`); } else { expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`); expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`); - if (end !== 0) { - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${end} DAY)) AS end_date`); - } else { - expect(result).toContain('DATE(NOW()) AS end_date'); - } + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${end} DAY)) AS end_date`); } } @@ -92,7 +102,7 @@ describe('getDateRangesCommonTableExpressionQuery', () => { ])('%s', (dbType: DatabaseConfig['type']) => { describe('hour periodicity (1 day - startDate == endDate)', () => { test('last 24 hours (endDate is today)', () => { - const startDate = now.startOf('day').toJSDate(); + const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); @@ -112,23 +122,23 @@ describe('getDateRangesCommonTableExpressionQuery', () => { } }); - test('yesterday (specific day)', () => { - const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + test('day before yesterday (specific day)', () => { + const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 2, - startDateOffset: 1, - endDateOffset: 0, // the end of the range is the start of the next day + prevStartDateOffset: 3, + startDateOffset: 2, + endDateOffset: 1, }); }); test('7 days ago (specific day)', () => { const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ @@ -142,7 +152,7 @@ describe('getDateRangesCommonTableExpressionQuery', () => { test('14 days ago (specific day)', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 13 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ @@ -157,7 +167,7 @@ describe('getDateRangesCommonTableExpressionQuery', () => { test('X days ago (specific day far in the past)', () => { // 109 days ago (2025-06-21) const startDate = now.minus({ days: 109 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 109 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 108 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ @@ -165,7 +175,7 @@ describe('getDateRangesCommonTableExpressionQuery', () => { dbType, prevStartDateOffset: 110, startDateOffset: 109, - endDateOffset: 108, // the end of the range is the start of the next day + endDateOffset: 108, }); }); }); @@ -173,113 +183,113 @@ describe('getDateRangesCommonTableExpressionQuery', () => { describe('day periodicity (2-30 days)', () => { describe('last X days (endDate is today)', () => { test('last 7 days', () => { - const startDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectLastXDaysDateRangeQuery({ result, dbType, - prevStartDateOffset: 13, // 6 + 7 - startDateOffset: 6, // today - 6 days ago = 7 days range + prevStartDateOffset: 14, // 7 + 7 + startDateOffset: 7, }); }); test('last 14 days', () => { - const startDate = now.minus({ days: 13 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectLastXDaysDateRangeQuery({ result, dbType, - prevStartDateOffset: 27, // 13 + 14 - startDateOffset: 13, // today - 13 days ago = 14 days range + prevStartDateOffset: 28, // 14 + 14 + startDateOffset: 14, }); }); test('last 30 days', () => { - const startDate = now.minus({ days: 29 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 30 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectLastXDaysDateRangeQuery({ result, dbType, - prevStartDateOffset: 59, // 29 + 30 - startDateOffset: 29, // today - 29 days ago = 30 days range + prevStartDateOffset: 60, // 30 + 30 + startDateOffset: 30, }); }); }); describe('specific historical range', () => { test('2 day range', () => { - const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 3 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 4, // 2 + 2 - startDateOffset: 2, - endDateOffset: 0, // the end of the range is the start of the next day + prevStartDateOffset: 5, + startDateOffset: 3, + endDateOffset: 1, }); }); test('5 days range', () => { const startDate = now.minus({ days: 10 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 5 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 15, // 10 + 5 + prevStartDateOffset: 15, startDateOffset: 10, - endDateOffset: 5, // the end of the range is the start of the next day + endDateOffset: 5, }); }); test('7 days range', () => { - const startDate = now.minus({ days: 12 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 19, // 12 + 7 - startDateOffset: 12, - endDateOffset: 5, // the end of the range is the start of the next day + prevStartDateOffset: 21, + startDateOffset: 14, + endDateOffset: 7, }); }); test('14 days range', () => { - const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 15 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 28, // 14 + 14 - startDateOffset: 14, - endDateOffset: 0, // the end of the range is the start of the next day + prevStartDateOffset: 29, + startDateOffset: 15, + endDateOffset: 1, }); }); test('30 days range', () => { - const startDate = now.minus({ days: 52 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 53 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 23 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 82, // 52 + 30 - startDateOffset: 52, - endDateOffset: 22, // the end of the range is the start of the next day + prevStartDateOffset: 83, + startDateOffset: 53, + endDateOffset: 23, }); }); }); @@ -288,15 +298,15 @@ describe('getDateRangesCommonTableExpressionQuery', () => { describe('week periodicity (31+ days)', () => { describe('last X days (endDate is today)', () => { test('last 90 days', () => { - const startDate = now.minus({ days: 89 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 90 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectLastXDaysDateRangeQuery({ result, dbType, - prevStartDateOffset: 179, // 89 + 90 - startDateOffset: 89, + prevStartDateOffset: 180, + startDateOffset: 90, }); }); @@ -306,7 +316,7 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2 + 1; + const prevDaysBack = daysBack * 2; expectLastXDaysDateRangeQuery({ result, dbType, @@ -321,7 +331,7 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2 + 1; + const prevDaysBack = daysBack * 2; expectLastXDaysDateRangeQuery({ result, dbType, @@ -333,58 +343,58 @@ describe('getDateRangesCommonTableExpressionQuery', () => { describe('specific historical range', () => { test('31 days range (specific historical range)', () => { - const startDate = now.minus({ days: 31 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 32 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 62, // 31 + 31 - startDateOffset: 31, - endDateOffset: 0, // the end of the range is the start of the next day + prevStartDateOffset: 63, + startDateOffset: 32, + endDateOffset: 1, }); }); test('90 days range (specific historical range)', () => { - const startDate = now.minus({ days: 97 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 98 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 8 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 187, // 97 + 90 - startDateOffset: 97, - endDateOffset: 7, // the end of the range is the start of the next day + prevStartDateOffset: 188, + startDateOffset: 98, + endDateOffset: 8, }); }); test('180 days range (specific historical range)', () => { - const startDate = now.minus({ days: 180 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 181 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 360, // 180 + 180 - startDateOffset: 180, - endDateOffset: 0, // the end of the range is the start of the next day + prevStartDateOffset: 361, + startDateOffset: 181, + endDateOffset: 1, }); }); test('360 days range (specific historical range)', () => { - const startDate = now.minus({ days: 360 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 361 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); expectStartOfDayDateRangeQuery({ result, dbType, - prevStartDateOffset: 720, // 360 + 360 - startDateOffset: 360, - endDateOffset: 0, // the end of the range is the start of the next day + prevStartDateOffset: 721, + startDateOffset: 361, + endDateOffset: 1, }); }); }); @@ -392,33 +402,91 @@ describe('getDateRangesCommonTableExpressionQuery', () => { describe('edge cases', () => { test('handles date with time component correctly', () => { - // Date with time should be treated as start of day + // Oct 6 14:30 to Oct 7 18:45 + // Now is Oct 8 8:51:27, so Oct 7 18:45 is less than 1 full day ago + // Therefore useStartOfDay = false (not yet a full day in the past) + // daysFromEndDateToToday = Math.round(0.58) = 1 + // daysDiff = Math.round(1.18) = 1 + // daysFromStartDateToToday = Math.floor(1.76) = 1 + // prevStartDaysFromToday = 1 + 1 = 2 const startDate = DateTime.utc(2025, 10, 6, 14, 30, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 18, 45, 30).toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - // 2-day range - expectStartOfDayDateRangeQuery({ - result, - dbType, - prevStartDateOffset: 4, // 2 + 2 - startDateOffset: 2, - endDateOffset: 0, // the end of the range is the start of the next day - }); + + // Verify the actual output based on the calculation + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-2 days') AS prev_start_date"); + expect(result).toContain("datetime('now', '-1 days') AS start_date"); + expect(result).toContain("datetime('now', '-1 days') AS end_date"); + } else if (dbType === 'postgresdb') { + expect(result).toContain("NOW() - INTERVAL '2 days' AS prev_start_date"); + expect(result).toContain("NOW() - INTERVAL '1 days' AS start_date"); + expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date"); + } else { + expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY) AS prev_start_date'); + expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS start_date'); + expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date'); + } }); test('handles same day with different times correctly (hour periodicity)', () => { + // Oct 7 9:00 to Oct 7 17:00 (same day) + // Now is Oct 8 8:51:27, so Oct 7 17:00 is less than 1 full day ago + // useStartOfDay = false + // daysDiff = 0 (same day), daysFromEndDateToToday = 1 (rounded) + // daysFromStartDateToToday = 0 (floored), prevStartDaysFromToday = 0 + 0 = 0 const startDate = DateTime.utc(2025, 10, 7, 9, 0, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 17, 0, 0).toJSDate(); const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ + + // Verify the actual output based on the calculation + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now') AS prev_start_date"); + expect(result).toContain("datetime('now') AS start_date"); + expect(result).toContain("datetime('now', '-1 days') AS end_date"); + } else if (dbType === 'postgresdb') { + expect(result).toContain('NOW() AS prev_start_date'); + expect(result).toContain('NOW() AS start_date'); + expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date"); + } else { + expect(result).toContain('NOW() AS prev_start_date'); + expect(result).toContain('NOW() AS start_date'); + expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date'); + } + }); + + test('handles daylight saving time transition correctly', () => { + // Simulate DST transition: Oct 22 (GMT+0200) to Nov 5 (GMT+0100) + // Same wall-clock time but different timezone offset + // Oct 26 2025 is when DST ends in Europe (clocks go back 1 hour) + const startDate = DateTime.fromObject( + { year: 2025, month: 10, day: 22, hour: 12, minute: 37, second: 56 }, + { zone: 'Europe/Paris' }, + ).toJSDate(); + const endDate = DateTime.fromObject( + { year: 2025, month: 11, day: 5, hour: 12, minute: 37, second: 56 }, + { zone: 'Europe/Paris' }, + ).toJSDate(); + + // Mock current time to be Nov 5 + jest.setSystemTime(endDate); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + // With DST normalization: Oct 22 to Nov 5 is 14 calendar days + // The function detects same wall-clock time but different timezone offset + // and normalizes to calculate correct calendar days + expectLastXDaysDateRangeQuery({ result, dbType, - prevStartDateOffset: 2, // 1 + 1 - startDateOffset: 1, - endDateOffset: 0, // the end of the range is the start of the next day + prevStartDateOffset: 28, // 14 + 14 + startDateOffset: 14, }); + + // Restore original mock time + jest.setSystemTime(now.toJSDate()); }); }); }); diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts index aa612b45ff5..2f3bfd8a2df 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts @@ -84,47 +84,63 @@ export const getDateRangesCommonTableExpressionQuery = ({ startDate: Date; endDate: Date; }) => { - const today = DateTime.now().startOf('day'); - const startDateStartOfDay = DateTime.fromJSDate(startDate).startOf('day'); - const endDateStartOfDay = DateTime.fromJSDate(endDate).startOf('day'); + let today = DateTime.now(); + let startDateTime = DateTime.fromJSDate(startDate); + let endDateTime = DateTime.fromJSDate(endDate); - const daysFromEndDateToToday = Math.floor(today.diff(endDateStartOfDay, 'days').days); - const daysDiff = Math.floor(endDateStartOfDay.diff(startDateStartOfDay, 'days').days); + // If the end date is in a past day, use start of day for both dates + const useStartOfDay = today.diff(endDateTime, 'days').days >= 1; - const isEndDateToday = daysFromEndDateToToday === 0; - - let prevStartDateSql: string; - let startDateSql: string; - let endDateSql: string; - - if (daysDiff === 0 && isEndDateToday) { - // Last 24 hours - prevStartDateSql = getDatetimeSql({ dbType, daysFromToday: 2, useStartOfDay: false }); - startDateSql = getDatetimeSql({ dbType, daysFromToday: 1, useStartOfDay: false }); - endDateSql = getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }); - } else { - const dateRangeInDays = daysDiff + 1; - - const daysFromStartDateToToday = Math.floor(today.diff(startDateStartOfDay, 'days').days); - const prevStartDaysFromToday = daysFromStartDateToToday + dateRangeInDays; - - prevStartDateSql = getDatetimeSql({ - dbType, - daysFromToday: prevStartDaysFromToday, - useStartOfDay: true, - }); - - startDateSql = getDatetimeSql({ - dbType, - daysFromToday: daysFromStartDateToToday, - useStartOfDay: true, - }); - - endDateSql = isEndDateToday - ? getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }) - : getDatetimeSql({ dbType, daysFromToday: daysFromEndDateToToday - 1, useStartOfDay: true }); + if (useStartOfDay) { + startDateTime = startDateTime.startOf('day'); + endDateTime = endDateTime.startOf('day'); + today = today.startOf('day'); } + // Check if times are exactly the same but timezone differs (DST transition case) + const offsetDiff = Math.abs(startDateTime.offset - endDateTime.offset); + + // If same wall-clock time but different timezone offset (max 2 hours), normalize to same timezone + if ( + startDateTime.hour === endDateTime.hour && + startDateTime.minute === endDateTime.minute && + startDateTime.second === endDateTime.second && + offsetDiff > 0 && + offsetDiff <= 120 // Max 2 hours difference in minutes + ) { + // Change the startDateTime to so that time matches + startDateTime = startDateTime.plus({ minutes: offsetDiff }); + } + + // Convert to UTC to avoid DST issues when calculating day differences + const startDateTimeUTC = startDateTime.toUTC(); + const endDateTimeUTC = endDateTime.toUTC(); + const todayUTC = today.toUTC(); + + const daysFromEndDateToToday = Math.round(todayUTC.diff(endDateTimeUTC, 'days').days); + const daysDiff = Math.round(endDateTimeUTC.diff(startDateTimeUTC, 'days').days); + + const daysFromStartDateToToday = Math.floor(todayUTC.diff(startDateTimeUTC, 'days').days); + const prevStartDaysFromToday = daysFromStartDateToToday + daysDiff; + + const prevStartDateSql = getDatetimeSql({ + dbType, + daysFromToday: prevStartDaysFromToday, + useStartOfDay, + }); + + const startDateSql = getDatetimeSql({ + dbType, + daysFromToday: daysFromStartDateToToday, + useStartOfDay, + }); + + const endDateSql = getDatetimeSql({ + dbType, + daysFromToday: daysFromEndDateToToday, + useStartOfDay, + }); + return sql`SELECT ${prevStartDateSql} AS prev_start_date, ${startDateSql} AS start_date, diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index f239e630d37..53fec84d413 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -152,7 +152,7 @@ export class InsightsController { if (!query.startDate) { return { - startDate: DateTime.now().minus({ days: 6 }).toJSDate(), + startDate: DateTime.now().minus({ days: 7 }).toJSDate(), endDate: today, }; } diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts index bd291171715..0e5568a80d8 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts @@ -207,16 +207,16 @@ let projectsStore: MockedStore; const personalProject = createProjectListItem('personal'); const teamProjects = Array.from({ length: 2 }, () => createProjectListItem('team')); const projects = [personalProject, ...teamProjects]; -const date = new Date(2000, 11, 19); +const date = new Date('2000-12-19T00:00:00.000Z'); // Test helper constants const DEFAULT_DATE_RANGE = { - startDate: new Date('2000-12-13T00:00:00.000Z'), + startDate: new Date('2000-12-12T00:00:00.000Z'), endDate: new Date('2000-12-19T00:00:00.000Z'), }; const SINGLE_DAY_RANGE = { - startDate: new Date('2000-12-19T00:00:00.000Z'), + startDate: new Date('2000-12-18T00:00:00.000Z'), endDate: new Date('2000-12-19T00:00:00.000Z'), }; @@ -243,7 +243,7 @@ const expectStoreExecutions = (params: { }; const openDatePicker = async (getByText: (text: string, options?: object) => HTMLElement) => { - const trigger = getByText('13 Dec - 19 Dec, 2000', { selector: 'button' }); + const trigger = getByText('12 Dec - 19 Dec, 2000', { selector: 'button' }); expect(trigger).toBeInTheDocument(); await userEvent.click(trigger); diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue index c11d5d7731c..a77091bd58e 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue @@ -118,7 +118,7 @@ const range = shallowRef<{ start: DateValue; end: DateValue; }>({ - start: maxDate.copy().subtract({ days: 6 }), + start: maxDate.copy().subtract({ days: 7 }), end: maxDate.copy(), }); diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDataRangePicker.vue b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDataRangePicker.vue index a26c5b57270..31b354e6fd6 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDataRangePicker.vue +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDataRangePicker.vue @@ -41,7 +41,7 @@ function getDaysDiff({ start, end }: DateRange) { if (!start) return 0; if (!end) return 0; - return end.compare(start) + 1; + return end.compare(start); } function isBeforeOrSame(dateToCompare: DateValue, referenceDate: DateValue): boolean { diff --git a/packages/frontend/editor-ui/src/features/execution/insights/insights.utils.ts b/packages/frontend/editor-ui/src/features/execution/insights/insights.utils.ts index edfb2e4cc6b..e3f3b3131a3 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/insights.utils.ts +++ b/packages/frontend/editor-ui/src/features/execution/insights/insights.utils.ts @@ -60,13 +60,13 @@ export const transformInsightsSummary = (data: InsightsSummary | null): Insights : []; export const timeRangeMappings = { - day: 0, - week: 6, - '2weeks': 13, - month: 29, - quarter: 89, + day: 1, + week: 7, + '2weeks': 14, + month: 30, + quarter: 90, '6months': 180, - year: 364, + year: 365, }; export const getTimeRangeLabels = () => { From 27fd768deb9a1fc4e38cc8a524d224049c210da8 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 4 Nov 2025 16:26:35 +0100 Subject: [PATCH 03/50] fix(core): Include role in user-invite-email-click (#21546) --- .../db/src/repositories/user.repository.ts | 8 ++++- packages/@n8n/decorators/src/redactable.ts | 4 +-- .../cli/src/controllers/auth.controller.ts | 4 ++- .../cli/src/events/maps/relay.event-map.ts | 2 +- .../cli/test/integration/auth.api.test.ts | 32 +++++++++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 0ebe9ef2b7d..f20e992fd46 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -12,9 +12,15 @@ export class UserRepository extends Repository { super(User, dataSource.manager); } - async findManyByIds(userIds: string[]) { + async findManyByIds( + userIds: string[], + options?: { + includeRole: boolean; + }, + ) { return await this.find({ where: { id: In(userIds) }, + relations: options?.includeRole ? ['role'] : undefined, }); } diff --git a/packages/@n8n/decorators/src/redactable.ts b/packages/@n8n/decorators/src/redactable.ts index 3f54253a031..cc9b1d6dae2 100644 --- a/packages/@n8n/decorators/src/redactable.ts +++ b/packages/@n8n/decorators/src/redactable.ts @@ -5,7 +5,7 @@ type UserLike = { email?: string; firstName?: string; lastName?: string; - role: { + role?: { slug: string; }; }; @@ -24,7 +24,7 @@ function toRedactable(userLike: UserLike) { _email: userLike.email, _firstName: userLike.firstName, _lastName: userLike.lastName, - globalRole: userLike.role.slug, + globalRole: userLike.role?.slug, }; } diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 9892bf0d106..617614c3d01 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -150,7 +150,9 @@ export class AuthController { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } - const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); + const users = await this.userRepository.findManyByIds([inviterId, inviteeId], { + includeRole: true, + }); if (users.length !== 2) { this.logger.debug( diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 6de204395f6..b9ed1513952 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -16,7 +16,7 @@ export type UserLike = { email?: string; firstName?: string; lastName?: string; - role: { + role?: { slug: string; }; }; diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 3412daf080d..b4f0aa87e50 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -12,6 +12,8 @@ import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; import { createUser, createUserShell } from './shared/db/users'; import type { SuperAgentTest } from './shared/types'; import * as utils from './shared/utils/'; +import { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; let owner: User; let authOwnerAgent: SuperAgentTest; @@ -392,6 +394,36 @@ describe('GET /resolve-signup-token', () => { expect(response.statusCode).toBe(400); } }); + + test('should send roles for user-invite-email-click event', async () => { + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); + + const eventService = Container.get(EventService); + const emitSpy = jest.spyOn(eventService, 'emit'); + + await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId: memberShell.id }) + .expect(200); + + // Check all emitted events + let foundEvent = false; + for (const [eventName, payload] of emitSpy.mock.calls) { + if (eventName === 'user-invite-email-click') { + foundEvent = true; + expect(payload).toBeDefined(); + const { invitee, inviter } = payload as RelayEventMap['user-invite-email-click']; + expect(invitee.role).toBeDefined(); + expect(invitee.role?.slug).toBe('global:member'); + expect(inviter.role).toBeDefined(); + expect(inviter.role?.slug).toBe('global:owner'); + } + } + + expect(foundEvent).toBe(true); + emitSpy.mockRestore(); + }); }); describe('POST /logout', () => { From b0df43828604c23b6d95ed10465e2951b251355e Mon Sep 17 00:00:00 2001 From: Idir Ouhab Meskine Date: Tue, 4 Nov 2025 16:48:37 +0100 Subject: [PATCH 04/50] feat(Redis Node): Add list length (LLEN) operation (#21420) --- packages/nodes-base/nodes/Redis/Redis.node.ts | 44 ++++++++++++++++++- .../nodes/Redis/__tests__/Redis.node.test.ts | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Redis/Redis.node.ts b/packages/nodes-base/nodes/Redis/Redis.node.ts index 1e3580721da..b30470a1b18 100644 --- a/packages/nodes-base/nodes/Redis/Redis.node.ts +++ b/packages/nodes-base/nodes/Redis/Redis.node.ts @@ -74,6 +74,12 @@ export class Redis implements INodeType { description: 'Returns all the keys matching a pattern', action: 'Return all keys matching a pattern', }, + { + name: 'List Length', + value: 'llen', + description: 'Returns the length of a list', + action: 'Return the length of a list', + }, { name: 'Pop', value: 'pop', @@ -286,6 +292,35 @@ export class Redis implements INodeType { default: true, description: 'Whether to get the value of matching keys', }, + { + displayName: 'List', + name: 'list', + type: 'string', + displayOptions: { + show: { + operation: ['llen'], + }, + }, + default: '', + required: true, + description: 'Name of the list in Redis', + }, + // ---------------------------------- + // llen + // ---------------------------------- + { + displayName: 'List', + name: 'list', + type: 'string', + displayOptions: { + show: { + operation: ['llen'], + }, + }, + default: '', + required: true, + description: 'Name of the list in Redis', + }, // ---------------------------------- // set // ---------------------------------- @@ -539,7 +574,9 @@ export class Redis implements INodeType { } } } else if ( - ['delete', 'get', 'keys', 'set', 'incr', 'publish', 'push', 'pop'].includes(operation) + ['delete', 'get', 'keys', 'llen', 'set', 'incr', 'publish', 'push', 'pop'].includes( + operation, + ) ) { const items = this.getInputData(); @@ -584,6 +621,11 @@ export class Redis implements INodeType { item.json[keyName] = await getValue(client, keyName); } returnItems.push(item); + } else if (operation === 'llen') { + const redisList = this.getNodeParameter('list', itemIndex) as string; + const length = await client.lLen(redisList); + item.json = { [redisList]: length }; + returnItems.push(item); } else if (operation === 'set') { const keySet = this.getNodeParameter('key', itemIndex) as string; const value = this.getNodeParameter('value', itemIndex) as string; diff --git a/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts b/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts index 4ea2c04d0dc..ef4649a1c7b 100644 --- a/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts +++ b/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts @@ -519,6 +519,40 @@ master_failover_state:no-failover expect(mockClient.get).toHaveBeenCalled(); expect(mockClient.quit).toHaveBeenCalled(); }); + + describe('llen operation', () => { + beforeEach(() => { + thisArg.getInputData.mockReturnValue([{ json: { x: 1 } }]); + thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('llen'); + thisArg.getNodeParameter.calledWith('list', 0).mockReturnValue('bull:main:q'); + }); + + it('should return the length of a list', async () => { + mockClient.lLen.calledWith('bull:main:q').mockResolvedValue(42); + + const output = await node.execute.call(thisArg); + expect(mockClient.lLen).toHaveBeenCalledWith('bull:main:q'); + expect(output[0][0].json).toEqual({ 'bull:main:q': 42 }); + }); + + it('should continue and return an error when continue on fail is enabled and an error is thrown', async () => { + thisArg.continueOnFail.mockReturnValue(true); + mockClient.lLen.mockRejectedValue(new Error('Redis error')); + + const output = await node.execute.call(thisArg); + expect(mockClient.lLen).toHaveBeenCalled(); + expect(output[0][0].json).toEqual({ error: 'Redis error' }); + }); + + it('should throw an error when continue on fail is disabled and an error is thrown', async () => { + mockClient.lLen.mockRejectedValue(new Error('Redis error')); + + await expect(node.execute.call(thisArg)).rejects.toThrow(NodeOperationError); + + expect(mockClient.lLen).toHaveBeenCalled(); + expect(mockClient.quit).toHaveBeenCalled(); + }); + }); }); }); }); From 428f816c8f62fd0e5613ae7da93f91159fa38aef Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 4 Nov 2025 18:06:04 +0200 Subject: [PATCH 05/50] fix(editor): Fix polyfills generation (no-changelog) (#21505) --- packages/frontend/editor-ui/package.json | 2 +- packages/frontend/editor-ui/src/App.vue | 2 - packages/frontend/editor-ui/vite.config.mts | 2 - pnpm-lock.yaml | 1898 ++++++++++--------- 4 files changed, 964 insertions(+), 940 deletions(-) diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 64f30db7c5c..922d460f303 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -127,7 +127,7 @@ "@types/jsonpath": "^0.2.0", "@types/lodash": "catalog:", "@types/uuid": "catalog:", - "@vitejs/plugin-legacy": "^6.0.2", + "@vitejs/plugin-legacy": "^7.2.1", "@vitejs/plugin-vue": "catalog:frontend", "@vitest/coverage-v8": "catalog:", "browserslist-to-esbuild": "^2.1.1", diff --git a/packages/frontend/editor-ui/src/App.vue b/packages/frontend/editor-ui/src/App.vue index 70071090ea0..02ae96b0619 100644 --- a/packages/frontend/editor-ui/src/App.vue +++ b/packages/frontend/editor-ui/src/App.vue @@ -1,6 +1,4 @@ + + + + diff --git a/packages/frontend/editor-ui/src/router.ts b/packages/frontend/editor-ui/src/router.ts index 5ea543507e2..672573ee4e5 100644 --- a/packages/frontend/editor-ui/src/router.ts +++ b/packages/frontend/editor-ui/src/router.ts @@ -34,6 +34,7 @@ const ChangePasswordView = async () => const ErrorView = async () => await import('@/app/views/ErrorView.vue'); const EntityNotFound = async () => await import('@/app/views/EntityNotFound.vue'); const EntityUnAuthorised = async () => await import('@/app/views/EntityUnAuthorised.vue'); +const OAuthConsentView = async () => await import('@/app/views/OAuthConsentView.vue'); const ForgotMyPasswordView = async () => await import('@/features/core/auth/views/ForgotMyPasswordView.vue'); const MainHeader = async () => await import('@/app/components/MainHeader/MainHeader.vue'); @@ -472,6 +473,16 @@ export const routes: RouteRecordRaw[] = [ middleware: ['authenticated'], }, }, + { + path: '/oauth/consent', + name: VIEWS.OAUTH_CONSENT, + components: { + default: OAuthConsentView, + }, + meta: { + middleware: ['authenticated'], + }, + }, { path: '/setup', name: VIEWS.SETUP, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd151f5f28c..dbf0adea704 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29326,7 +29326,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 20.19.21 '@types/tough-cookie': 4.0.5 - axios: 1.12.0(debug@4.4.3) + axios: 1.12.0(debug@4.3.6) camelcase: 6.3.0 debug: 4.4.3 dotenv: 16.6.1 @@ -29336,7 +29336,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.12.0) + retry-axios: 2.6.0(axios@1.12.0(debug@4.4.1)) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -33679,7 +33679,7 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - retry-axios@2.6.0(axios@1.12.0): + retry-axios@2.6.0(axios@1.12.0(debug@4.4.1)): dependencies: axios: 1.12.0(debug@4.4.1) From d9d36bf28f361d2c333b375744b4d7b51619e5a9 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Thu, 6 Nov 2025 09:13:44 +0200 Subject: [PATCH 11/50] fix: Ensure workflows and folders updatedAt/createdAt aren't mixed up in project sorting (#21484) --- packages/@n8n/db/src/repositories/workflow.repository.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 1ba40bfb522..1326d023a32 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -149,8 +149,14 @@ export class WorkflowRepository extends Repository { private buildBaseUnionQuery(workflowIds: string[], options: ListQuery.Options = {}) { const subQueryParameters: ListQuery.Options = { select: { - createdAt: true, + // For some reason the order of updatedAt and createdAt here is load-bearing + // and the generated sql queries below risk switching up the order otherwise + // depending on whether this code is called for a project or the overview + // A proper fix would sort the columnNames here and in the folder and workflow queries + // but that risks breaking other use cases + // https://linear.app/n8n/issue/ADO-4376/tech-debt-investigate-and-fix-root-cause-of-incorrect-sql-column updatedAt: true, + createdAt: true, id: true, name: true, }, From 276ae834a5f44e2c3df5ca01ffc5236687f4ba33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Thu, 6 Nov 2025 15:39:17 +0100 Subject: [PATCH 12/50] feat(editor): Add oAuth clients management page (no-changelog) (#21443) Co-authored-by: Ricardo Espinoza --- packages/@n8n/api-types/src/dto/index.ts | 5 + .../@n8n/api-types/src/dto/oauth/index.ts | 1 + .../src/dto/oauth/oauth-client.dto.ts | 41 ++ packages/@n8n/permissions/src/constants.ee.ts | 2 +- .../src/roles/scopes/global-scopes.ee.ts | 2 + .../mcp/__tests__/mcp-oauth-service.test.ts | 6 +- .../cli/src/modules/mcp/mcp-oauth-service.ts | 52 +- packages/cli/src/modules/mcp/mcp.module.ts | 1 + .../mcp/mcp.oauth-clients.controller.ts | 93 ++++ .../frontend/@n8n/i18n/src/locales/en.json | 13 + .../ai/mcpAccess/SettingsMCPView.test.ts | 51 -- .../features/ai/mcpAccess/SettingsMCPView.vue | 460 ++++-------------- .../components/ConnectionParameter.vue | 2 +- .../MCPConnectionInstructions.test.ts | 150 ++++++ .../components/MCPConnectionInstructions.vue | 278 ++--------- .../components/McpAccessToggle.test.ts | 199 ++++++++ .../mcpAccess/components/McpAccessToggle.vue | 81 +++ .../components/WorkflowsTable.test.ts | 283 +++++++++++ .../mcpAccess/components/WorkflowsTable.vue | 294 +++++++++++ .../AccessTokenConnectionInstructions.vue | 254 ++++++++++ .../ConnectionParameter.vue | 94 ++++ .../OAuthClientsTable.test.ts | 242 +++++++++ .../OAuthClientsTable.vue | 137 ++++++ .../OAuthConnectionInstructions.vue | 135 +++++ .../src/features/ai/mcpAccess/mcp.api.ts | 23 +- .../src/features/ai/mcpAccess/mcp.store.ts | 21 +- 26 files changed, 2272 insertions(+), 648 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/oauth/index.ts create mode 100644 packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts create mode 100644 packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/ConnectionParameter.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 2a980ebcc0e..f54a4341ab3 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -100,4 +100,9 @@ export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto'; export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto'; export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto'; +export { + OAuthClientResponseDto, + ListOAuthClientsResponseDto, + DeleteOAuthClientResponseDto, +} from './oauth/oauth-client.dto'; export { ProvisioningConfigDto, ProvisioningConfigPatchDto } from './provisioning/config.dto'; diff --git a/packages/@n8n/api-types/src/dto/oauth/index.ts b/packages/@n8n/api-types/src/dto/oauth/index.ts new file mode 100644 index 00000000000..67fb51efbb6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/oauth/index.ts @@ -0,0 +1 @@ +export * from './oauth-client.dto'; diff --git a/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts new file mode 100644 index 00000000000..8b1bef34330 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +/** + * DTO for OAuth client response (excludes sensitive data like clientSecret) + */ +export class OAuthClientResponseDto extends Z.class({ + id: z.string(), + name: z.string(), + redirectUris: z.array(z.string()), + grantTypes: z.array(z.string()), + tokenEndpointAuthMethod: z.string(), + createdAt: z.string().datetime(), // Using string for date serialization over HTTP + updatedAt: z.string().datetime(), +}) {} + +/** + * DTO for listing OAuth clients response + */ +export class ListOAuthClientsResponseDto extends Z.class({ + data: z.array( + z.object({ + id: z.string(), + name: z.string(), + redirectUris: z.array(z.string()), + grantTypes: z.array(z.string()), + tokenEndpointAuthMethod: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }), + ), + count: z.number(), +}) {} + +/** + * DTO for deleting an OAuth client response + */ +export class DeleteOAuthClientResponseDto extends Z.class({ + success: z.boolean(), + message: z.string(), +}) {} diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 9890040c0ad..f9e83b1ff4d 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -32,7 +32,7 @@ export const RESOURCES = { execution: ['delete', 'read', 'retry', 'list', 'get'] as const, workflowTags: ['update', 'list'] as const, role: ['manage'] as const, - mcp: ['manage'] as const, + mcp: ['manage', 'oauth'] as const, mcpApiKey: ['create', 'rotate'] as const, chatHub: ['manage', 'message'] as const, chatHubAgent: [...DEFAULT_OPERATIONS] as const, diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 22853a71d59..2f3720f3040 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -100,6 +100,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'dataTable:writeRow', 'role:manage', 'mcp:manage', + 'mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate', 'chatHub:manage', @@ -130,6 +131,7 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'variable:list', 'variable:read', 'dataTable:list', + 'mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate', 'chatHub:message', diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts index d05d35c20c1..9a2b77127cf 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts @@ -1,11 +1,12 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; -import { mock } from 'jest-mock-extended'; import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity'; import type { OAuthClient } from '../database/entities/oauth-client.entity'; import { OAuthClientRepository } from '../database/repositories/oauth-client.repository'; +import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository'; import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service'; import { McpOAuthService, SUPPORTED_SCOPES } from '../mcp-oauth-service'; import { McpOAuthTokenService } from '../mcp-oauth-token.service'; @@ -17,6 +18,7 @@ let oauthClientRepository: jest.Mocked; let tokenService: jest.Mocked; let authorizationCodeService: jest.Mocked; let service: McpOAuthService; +let userConsentRepository: jest.Mocked; describe('McpOAuthService', () => { beforeAll(() => { @@ -25,6 +27,7 @@ describe('McpOAuthService', () => { oauthClientRepository = mockInstance(OAuthClientRepository); tokenService = mockInstance(McpOAuthTokenService); authorizationCodeService = mockInstance(McpOAuthAuthorizationCodeService); + userConsentRepository = mockInstance(UserConsentRepository); service = new McpOAuthService( logger, @@ -32,6 +35,7 @@ describe('McpOAuthService', () => { oauthClientRepository, tokenService, authorizationCodeService, + userConsentRepository, ); }); diff --git a/packages/cli/src/modules/mcp/mcp-oauth-service.ts b/packages/cli/src/modules/mcp/mcp-oauth-service.ts index 419662b4eb8..e84127eb8f9 100644 --- a/packages/cli/src/modules/mcp/mcp-oauth-service.ts +++ b/packages/cli/src/modules/mcp/mcp-oauth-service.ts @@ -1,19 +1,21 @@ -import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients'; -import { +import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients'; +import type { AuthorizationParams, OAuthServerProvider, } from '@modelcontextprotocol/sdk/server/auth/provider'; -import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; -import { +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; +import type { OAuthClientInformationFull, OAuthTokens, OAuthTokenRevocationRequest, } from '@modelcontextprotocol/sdk/shared/auth'; import { Logger } from '@n8n/backend-common'; import { Service } from '@n8n/di'; -import { Response } from 'express'; +import type { Response } from 'express'; +import { OAuthClient } from './database/entities/oauth-client.entity'; import { OAuthClientRepository } from './database/repositories/oauth-client.repository'; +import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository'; import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service'; import { McpOAuthTokenService } from './mcp-oauth-token.service'; import { OAuthSessionService } from './oauth-session.service'; @@ -32,6 +34,7 @@ export class McpOAuthService implements OAuthServerProvider { private readonly oauthClientRepository: OAuthClientRepository, private readonly tokenService: McpOAuthTokenService, private readonly authorizationCodeService: McpOAuthAuthorizationCodeService, + private readonly userConsentRepository: UserConsentRepository, ) {} get clientsStore(): OAuthRegisteredClientsStore { @@ -187,4 +190,43 @@ export class McpOAuthService implements OAuthServerProvider { clientId: client.client_id, }); } + + /** + * Get all OAuth clients for a specific user (excluding sensitive data) + */ + async getAllClients( + userId: string, + ): Promise>> { + // Get all consents for the user with client information + const userConsents = await this.userConsentRepository.findByUserWithClient(userId); + + // Extract and sanitize the client information + return userConsents.map((consent) => { + const { clientSecret, clientSecretExpiresAt, ...sanitizedClient } = consent.client; + return sanitizedClient; + }); + } + + /** + * Delete an OAuth client and all related data + */ + async deleteClient(clientId: string): Promise { + // First check if the client exists + const client = await this.oauthClientRepository.findOne({ + where: { id: clientId }, + }); + + if (!client) { + throw new Error(`OAuth client with ID ${clientId} not found`); + } + + this.logger.info('Deleting OAuth client and related data', { clientId }); + + await this.oauthClientRepository.delete({ id: clientId }); + + this.logger.info('OAuth client deleted successfully', { + clientId, + clientName: client.name, + }); + } } diff --git a/packages/cli/src/modules/mcp/mcp.module.ts b/packages/cli/src/modules/mcp/mcp.module.ts index 2cf18ba2061..a0bb1dc7602 100644 --- a/packages/cli/src/modules/mcp/mcp.module.ts +++ b/packages/cli/src/modules/mcp/mcp.module.ts @@ -14,6 +14,7 @@ export class McpModule implements ModuleInterface { await import('./mcp.settings.controller'); await import('./mcp.oauth.controller'); await import('./mcp.auth.consent.controller'); + await import('./mcp.oauth-clients.controller'); // Initialize event relay to handle workflow deactivation const { McpEventRelay } = await import('./mcp.event-relay'); diff --git a/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts b/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts new file mode 100644 index 00000000000..453f8eabddd --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts @@ -0,0 +1,93 @@ +import { + DeleteOAuthClientResponseDto, + ListOAuthClientsResponseDto, + OAuthClientResponseDto, +} from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { AuthenticatedRequest } from '@n8n/db'; +import { Delete, Get, GlobalScope, Param, RestController } from '@n8n/decorators'; +import type { Response } from 'express'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { McpOAuthService } from './mcp-oauth-service'; + +@RestController('/mcp/oauth-clients') +export class McpOAuthClientsController { + constructor( + private readonly mcpOAuthService: McpOAuthService, + private readonly logger: Logger, + ) {} + + /** + * Get all OAuth clients for the current user + */ + @GlobalScope('mcp:oauth') + @Get('/') + async getAllClients( + req: AuthenticatedRequest, + _res: Response, + ): Promise { + this.logger.debug('Fetching all OAuth clients for user', { userId: req.user.id }); + + const clients = await this.mcpOAuthService.getAllClients(req.user.id); + + this.logger.debug(`Found ${clients.length} OAuth clients`); + + const clientDtos: OAuthClientResponseDto[] = clients.map((client) => ({ + id: client.id, + name: client.name, + redirectUris: client.redirectUris, + grantTypes: client.grantTypes, + tokenEndpointAuthMethod: client.tokenEndpointAuthMethod, + createdAt: client.createdAt.toISOString(), + updatedAt: client.updatedAt.toISOString(), + })); + + return { + data: clientDtos, + count: clients.length, + }; + } + + /** + * Delete an OAuth client by ID + * This will cascade delete all related tokens, authorization codes, and user consents + */ + @GlobalScope('mcp:oauth') + @Delete('/:clientId') + async deleteClient( + req: AuthenticatedRequest, + _res: Response, + @Param('clientId') clientId: string, + ): Promise { + this.logger.info('Deleting OAuth client', { + clientId, + userId: req.user.id, + userEmail: req.user.email, + }); + + try { + await this.mcpOAuthService.deleteClient(clientId); + + this.logger.info('OAuth client deleted successfully', { + clientId, + userId: req.user.id, + }); + + return { + success: true, + message: `OAuth client ${clientId} has been deleted successfully`, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + this.logger.warn('Attempted to delete non-existent OAuth client', { + clientId, + userId: req.user.id, + }); + throw new NotFoundError(`OAuth client with ID ${clientId} not found`); + } + throw error; + } + } +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 67e83c0c7cf..6d0bd8f6684 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2200,6 +2200,8 @@ "settings.mcp.empty.description": "Enable MCP access in each workflow's settings to see them here.", "settings.mcp.toggle.disabled.tooltip": "Only instance admins can change this", "settings.mcp.toggle.error": "Error updating MCP access", + "settings.mcp.instructions.tabs.oauth": "oAuth", + "settings.mcp.instructions.tabs.apiKey": "Access Token", "settings.mcp.instructions.enableAccess": "Enable workflow access in at least one workflow via its settings", "settings.mcp.instructions.serverUrl": "Server URL", "settings.mcp.instructions.apiKey.label": "Access token", @@ -2211,6 +2213,17 @@ "settings.mcp.newKey.notice": "Make sure to copy your access token now. You won’t be able to see or copy it again!", "settings.mcp.error.fetching.apiKey": "Error fetching access token", "settings.mcp.error.rotating.apiKey": "Error generating new access token", + "settings.mcp.error.fetching.oAuthClients": "Error fetching list of OAuth clients", + "settings.mcp.oAuthClients.heading": "Connected oAuth clients", + "settings.mcp.oAuthClients.table.clientName": "Client Name", + "settings.mcp.oAuthClients.table.connectedAt": "Connected At", + "settings.mcp.oAuthClients.table.lastUsedAt": "Last Used At", + "settings.mcp.oAuthClients.table.action.revokeAccess": "Revoke Access", + "settings.mcp.oAuthClients.revoke.success.title": "Access revoked", + "settings.mcp.oAuthClients.revoke.success.message": "Client {name} access has been revoked", + "settings.mcp.oAuthClients.revoke.error": "Error revoking client access", + "settings.mcp.oAuthClients.table.empty.title": "No oAuth clients connected", + "settings.mcp.refresh.tooltip": "Refresh list", "settings.goBack": "Go back", "settings.personal": "Personal", "settings.personal.basicInformation": "Basic Information", diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts index ec47da3beb1..5d45fa56df1 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts @@ -7,7 +7,6 @@ import { useRootStore } from '@n8n/stores/useRootStore'; import { useUsersStore } from '@/features/settings/users/users.store'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { useMCPStore } from './mcp.store'; -import { MCP_WORKFLOWS } from './SettingsMCPView.test.constants'; vi.mock('@/app/composables/useDocumentTitle', () => ({ useDocumentTitle: () => ({ @@ -85,54 +84,4 @@ describe('SettingsMCPView', () => { expect(toggle).toHaveClass('is-disabled'); }); - - test('shows an empty state when no workflows are available', async () => { - mcpStore.mcpAccessEnabled = true; - - const { findByTestId } = renderComponent({ pinia }); - await waitAllPromises(); - - expect(await findByTestId('empty-workflow-list-box')).toBeInTheDocument(); - }); - - test('shows workflows table when there are available workflows', async () => { - mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue(MCP_WORKFLOWS); - mcpStore.mcpAccessEnabled = true; - - const { getByTestId } = renderComponent({ pinia }); - await waitAllPromises(); - - expect(getByTestId('mcp-workflow-list')).toBeInTheDocument(); - expect(getByTestId('mcp-workflow-table')).toBeInTheDocument(); - - // Should render both workflow info correctly - const rows = getByTestId('mcp-workflow-table').querySelectorAll('table tbody tr'); - - expect(rows).toHaveLength(MCP_WORKFLOWS.length); - - rows.forEach((row, index) => { - const workflow = MCP_WORKFLOWS[index]; - - expect(row.querySelector('[data-test-id=mcp-workflow-name]')).toHaveTextContent( - workflow.name, - ); - - if (workflow.parentFolder) { - expect(row.querySelector('[data-test-id=mcp-workflow-folder-link]')).toBeInTheDocument(); - expect(row.querySelector('[data-test-id=mcp-workflow-folder-name]')).toHaveTextContent( - workflow.parentFolder.name, - ); - } else { - expect(row.querySelector('[data-test-id=mcp-workflow-no-folder]')).toBeInTheDocument(); - } - - const projectNameCell = row.querySelector('[data-test-id=mcp-workflow-project-name]'); - - if (workflow.homeProject?.type === 'personal') { - expect(projectNameCell).toHaveTextContent('Personal'); - } else { - expect(projectNameCell).toHaveTextContent(workflow.homeProject?.name || ''); - } - }); - }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue index 05fa519f21c..4ebcb5438e8 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue @@ -1,34 +1,21 @@ @@ -381,85 +199,5 @@ onMounted(async () => { display: flex; flex-direction: column; gap: var(--spacing--lg); - - :global(.table-pagination) { - display: none; - } -} - -.headingContainer { - margin-bottom: var(--spacing--xs); -} - -.mainToggleContainer { - display: flex; - align-items: center; - padding: var(--spacing--sm); - justify-content: space-between; - flex-shrink: 0; - - border-radius: var(--radius); - border: var(--border); -} - -.mainToggleInfo { - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; -} - -.mainTooggle { - display: flex; - justify-content: flex-end; - align-items: center; - flex-shrink: 0; -} - -.header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.workflow-table { - tr:last-child { - border-bottom: none !important; - } -} - -.table-link { - color: var(--color--text); - - :global(.n8n-text) { - display: flex; - align-items: center; - gap: var(--spacing--3xs); - - .link-icon { - display: none; - } - - &:hover { - .link-icon { - display: inline-flex; - } - } - } - - &.project-link { - :global(.n8n-text) { - gap: 0; - } - .link-icon { - margin-left: var(--spacing--3xs); - } - } -} - -.folder-cell { - display: flex; - align-items: center; - gap: var(--spacing--4xs); } diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue index 171a2399ea9..ba955389826 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue @@ -27,7 +27,7 @@ const props = withDefaults(defineProps(), {
diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.test.ts new file mode 100644 index 00000000000..35a50d2390d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.test.ts @@ -0,0 +1,150 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import MCPConnectionInstructions from './MCPConnectionInstructions.vue'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import type { ApiKey, OAuthClientResponseDto } from '@n8n/api-types'; +import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants'; + +const renderComponent = createComponentRenderer(MCPConnectionInstructions); + +let pinia: ReturnType; + +vi.mock( + '@/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue', + () => ({ + default: { + name: 'OAuthConnectionInstructions', + template: '
OAuth Instructions
', + props: ['serverUrl', 'clients', 'clientsLoading'], + }, + }), +); + +vi.mock( + '@/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue', + () => ({ + default: { + name: 'AccessTokenConnectionInstructions', + template: '
Token Instructions
', + props: ['serverUrl', 'apiKey', 'loadingApiKey'], + }, + }), +); + +describe('MCPConnectionInstructions', () => { + const mockApiKey: ApiKey = { + id: '123', + label: 'Test Key', + apiKey: 'test-api-key', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + expiresAt: null, + scopes: [], + }; + + const mockOAuthClients: OAuthClientResponseDto[] = [ + { + id: '1', + name: 'Test Client', + redirectUris: ['http://localhost/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'client_secret_basic', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ]; + + const defaultProps = { + baseUrl: 'http://localhost:5678', + apiKey: mockApiKey, + loadingApiKey: false, + oAuthClients: mockOAuthClients, + loadingOAuthClients: false, + }; + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + }); + + it('should render default configuration correctly', () => { + const { container, getByText, getByTestId } = renderComponent({ + pinia, + props: defaultProps, + }); + + // Main heading + expect(getByText('How to connect')).toBeInTheDocument(); + + // Both tabs + const tabs = container.querySelectorAll('.n8n-tabs .tab'); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveTextContent('oAuth'); + expect(tabs[1]).toHaveTextContent('Access Token'); + // OAuth tab should be active by default + expect(getByTestId('oauth-instructions')).toBeInTheDocument(); + expect(getByTestId('oauth-instructions')).toBeVisible(); + + // Documentation link + const docsText = getByTestId('mcp-connection-instructions-docs-text'); + expect(docsText).toBeInTheDocument(); + const docsLink = docsText.querySelector('a'); + expect(docsLink).toBeInTheDocument(); + expect(docsLink).toHaveAttribute('href', MCP_DOCS_PAGE_URL); + expect(docsLink).toHaveAttribute('target', '_blank'); + }); + + it('should switch to API Key tab when clicked', async () => { + const { container, getByTestId, queryByTestId } = renderComponent({ + pinia, + props: defaultProps, + }); + + // Find and click the API Key tab + const tabs = container.querySelectorAll('.n8n-tabs .tab'); + const oauthTab = tabs[0]; + const apiKeyTab = tabs[1]; + expect(apiKeyTab).toHaveTextContent('Access Token'); + + await userEvent.click(apiKeyTab); + + // OAuth instructions should be hidden + expect(getByTestId('token-instructions')).toBeInTheDocument(); + expect(queryByTestId('oauth-instructions')).not.toBeVisible(); + expect(oauthTab).not.toHaveClass('activeTab'); + + // Token instructions should be visible + expect(getByTestId('token-instructions')).toBeInTheDocument(); + expect(getByTestId('token-instructions')).toBeVisible(); + expect(apiKeyTab).toHaveClass('activeTab'); + }); + + it('should switch back to OAuth tab when clicked', async () => { + const { container, getByTestId, queryByTestId } = renderComponent({ + pinia, + props: defaultProps, + }); + + // First switch to API Key tab + const tabs = container.querySelectorAll('.n8n-tabs .tab'); + const oauthTab = tabs[0]; + const apiKeyTab = tabs[1]; + await userEvent.click(apiKeyTab); + + // Verify API Key tab is active + expect(queryByTestId('oauth-instructions')).not.toBeVisible(); + expect(oauthTab).not.toHaveClass('activeTab'); + expect(getByTestId('token-instructions')).toBeVisible(); + expect(apiKeyTab).toHaveClass('activeTab'); + + // Switch back to OAuth tab + await userEvent.click(oauthTab); + + // OAuth instructions should be visible again + expect(getByTestId('oauth-instructions')).toBeVisible(); + expect(oauthTab).toHaveClass('activeTab'); + expect(queryByTestId('token-instructions')).not.toBeVisible(); + expect(apiKeyTab).not.toHaveClass('activeTab'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue index 61e8611e1b7..ef16ce1df75 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue @@ -1,163 +1,83 @@ @@ -165,100 +85,6 @@ const apiKeyText = computed(() => { .container { display: flex; flex-direction: column; -} - -.instructions-container { - :global(.notice) { - margin: var(--spacing--sm) var(--spacing--lg) var(--spacing--md); - } -} - -.instructions { - display: flex; - flex-direction: column; - gap: var(--spacing--xs); - padding-left: var(--spacing--lg); - margin: var(--spacing--sm); - - li { - min-height: var(--spacing--lg); - } - - .item { - display: flex; - align-items: center; - gap: var(--spacing--2xs); - - :global(.n8n-loading) div { - height: 32px; - width: 300px; - margin: 0; - } - } - - .label { - font-size: var(--font-size--sm); - flex: none; - } - - .url { - display: flex; - align-items: stretch; - gap: var(--spacing--2xs); - background: var(--color--background--light-3); - border: var(--border); - border-radius: var(--radius); - font-size: var(--font-size--sm); - overflow: hidden; - - code { - text-overflow: ellipsis; - overflow: hidden; - white-space: pre; - padding: var(--spacing--2xs) var(--spacing--3xs); - } - - .copy-url-wrapper { - display: flex; - align-items: center; - border-left: var(--border); - } - - .copy-url-button { - border: none; - border-radius: 0; - } - - @media screen and (max-width: 820px) { - word-wrap: break-word; - margin-top: var(--spacing--2xs); - } - } -} - -.connectionString { - flex-grow: 1; - position: relative; - padding: 0 var(--spacing--lg); - - :global(.n8n-markdown) { - width: 100%; - } - code { - font-size: var(--font-size--xs); - } - - &:hover { - .copy-json-button { - display: flex; - } - } -} - -.copy-json-button { - position: absolute; - top: var(--spacing--xl); - right: var(--spacing--2xl); - display: none; + gap: var(--spacing--md); } diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.test.ts new file mode 100644 index 00000000000..bfa17c3b639 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.test.ts @@ -0,0 +1,199 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import McpAccessToggle from './McpAccessToggle.vue'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; + +const renderComponent = createComponentRenderer(McpAccessToggle); + +let pinia: ReturnType; + +describe('McpAccessToggle', () => { + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + }); + + describe('Component rendering', () => { + it('should handle modelValue and disabled prop combinations correctly', () => { + // Test modelValue=true, disabled=true + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: true, disabled: true }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).toHaveClass('is-checked'); + expect(toggle).toHaveClass('is-disabled'); + unmount(); + } + + // Test modelValue=true, disabled=false + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: true, disabled: false }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).toHaveClass('is-checked'); + expect(toggle).not.toHaveClass('is-disabled'); + unmount(); + } + + // Test modelValue=false, disabled=true + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: false, disabled: true }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).not.toHaveClass('is-checked'); + expect(toggle).toHaveClass('is-disabled'); + unmount(); + } + + // Test modelValue=false, disabled=false + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: false, disabled: false }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).not.toHaveClass('is-checked'); + expect(toggle).not.toHaveClass('is-disabled'); + unmount(); + } + }); + + it('should handle loading prop correctly', () => { + const { getByTestId, unmount: unmount1 } = renderComponent({ + pinia, + props: { modelValue: false, loading: true }, + }); + expect(getByTestId('mcp-access-toggle')).toBeInTheDocument(); + unmount1(); + + const { getByTestId: getByTestId2, unmount: unmount2 } = renderComponent({ + pinia, + props: { modelValue: false, loading: false }, + }); + expect(getByTestId2('mcp-access-toggle')).toBeInTheDocument(); + unmount2(); + }); + }); + + describe('Disabled state', () => { + it('should render as disabled when disabled prop is true', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: true, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).toHaveClass('is-disabled'); + }); + + it('should render as enabled when disabled prop is false', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: false, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).not.toHaveClass('is-disabled'); + }); + + it('should show tooltip when disabled', async () => { + const { container, queryByRole } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: true, + }, + }); + + const tooltipTrigger = container.querySelector('.el-tooltip__trigger'); + expect(tooltipTrigger).toBeInTheDocument(); + + await userEvent.hover(tooltipTrigger!); + await waitFor(() => { + const tooltip = queryByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Only instance admins can change this'); + }); + }); + + it('should not show tooltip when enabled', async () => { + const { container, queryByRole } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: false, + }, + }); + + const tooltipTrigger = container.querySelector('.el-tooltip__trigger'); + expect(tooltipTrigger).toBeInTheDocument(); + + await userEvent.hover(tooltipTrigger!); + + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + + describe('Event emissions', () => { + it('should emit toggleMcpAccess event with true when toggled on', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + modelValue: false, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + await userEvent.click(toggle); + + expect(emitted()).toHaveProperty('toggleMcpAccess'); + expect(emitted().toggleMcpAccess).toHaveLength(1); + expect(emitted().toggleMcpAccess[0]).toEqual([true]); + }); + + it('should emit toggleMcpAccess event with false when toggled off', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + modelValue: true, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + await userEvent.click(toggle); + + expect(emitted()).toHaveProperty('toggleMcpAccess'); + expect(emitted().toggleMcpAccess).toHaveLength(1); + expect(emitted().toggleMcpAccess[0]).toEqual([false]); + }); + + it('should not emit event when disabled', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: true, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + await userEvent.click(toggle); + + expect(emitted()).not.toHaveProperty('toggleMcpAccess'); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue new file mode 100644 index 00000000000..9ad3d3b15ba --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts new file mode 100644 index 00000000000..85586435ed2 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts @@ -0,0 +1,283 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import WorkflowsTable from './WorkflowsTable.vue'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import type { WorkflowListItem } from '@/Interface'; +import router from '@/router'; +import { VIEWS } from '@/app/constants'; + +const renderComponent = createComponentRenderer(WorkflowsTable); + +let pinia: ReturnType; + +// Mock router +vi.mock('@/router', () => ({ + default: { + resolve: vi.fn((route) => ({ + fullPath: `/mock-path/${route.params?.name || route.params?.projectId}`, + })), + }, +})); + +const mockWorkflow = (id: string, overrides?: Partial): WorkflowListItem => ({ + id, + name: `Workflow ${id}`, + active: true, + isArchived: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + versionId: 'v1', + resource: 'workflow', + homeProject: { + id: 'project-1', + name: 'Test Project', + type: 'team', + icon: { type: 'icon', value: 'layers' }, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + parentFolder: { + id: 'folder-1', + name: 'Test Folder', + parentFolderId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ...overrides, +}); + +describe('WorkflowsTable', () => { + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + vi.clearAllMocks(); + }); + + describe('Component rendering', () => { + it('should render loading state correctly', () => { + const { container, getByTestId } = renderComponent({ + pinia, + props: { + workflows: [], + loading: true, + }, + }); + + const loadingElements = container.querySelectorAll('.n8n-loading'); + expect(loadingElements.length).toBeGreaterThan(0); + + expect(() => getByTestId('mcp-workflow-table')).toThrow(); + expect(() => getByTestId('empty-workflow-list-box')).toThrow(); + }); + + it('should render empty state when no workflows exist', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [], + loading: false, + }, + }); + + expect(getByTestId('empty-workflow-list-box')).toBeInTheDocument(); + }); + + it('should render workflows table with correct data', () => { + const workflows = [mockWorkflow('1'), mockWorkflow('2', { name: 'Second Workflow' })]; + + const { getByTestId, getByText } = renderComponent({ + pinia, + props: { + workflows, + loading: false, + }, + }); + + const table = getByTestId('mcp-workflow-table'); + expect(table).toBeInTheDocument(); + + expect(getByText(`Available Workflows (${workflows.length})`)).toBeInTheDocument(); + + workflows.forEach((workflow) => { + expect(getByText(workflow.name)).toBeInTheDocument(); + }); + }); + + it('should render refresh button', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [mockWorkflow('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-workflows-refresh-button'); + expect(refreshButton).toBeInTheDocument(); + }); + }); + + describe('Table data display', () => { + it('should display workflow name as a link', () => { + const workflow = mockWorkflow('1'); + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const nameElement = getByTestId('mcp-workflow-name'); + expect(nameElement).toHaveTextContent(workflow.name); + + expect(router.resolve).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOW, + params: { name: '1' }, + }); + }); + + it('should display folder with link when homeProject exists', () => { + const workflow = mockWorkflow('1'); + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const folderLink = getByTestId('mcp-workflow-folder-link'); + expect(folderLink).toBeInTheDocument(); + + const folderName = getByTestId('mcp-workflow-folder-name'); + expect(folderName).toHaveTextContent(workflow.parentFolder?.name ?? ''); + }); + + it('should display folder without link when homeProject does not exist', () => { + const workflow = mockWorkflow('1', { + homeProject: undefined, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + expect(queryByTestId('mcp-workflow-folder-link')).not.toBeInTheDocument(); + const folderName = getByTestId('mcp-workflow-folder-name'); + expect(folderName).toHaveTextContent(workflow.parentFolder?.name ?? ''); + }); + + it('should display "-" when no folder exists', () => { + const workflow = mockWorkflow('1', { + parentFolder: undefined, + }); + + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const noFolder = getByTestId('mcp-workflow-no-folder'); + expect(noFolder).toHaveTextContent('-'); + }); + + it('should display project information correctly for team project', () => { + const workflow = mockWorkflow('1'); + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const projectLink = getByTestId('mcp-workflow-project-link'); + expect(projectLink).toBeInTheDocument(); + + const projectName = getByTestId('mcp-workflow-project-name'); + expect(projectName).toHaveTextContent(workflow.homeProject?.name ?? ''); + + expect(router.resolve).toHaveBeenCalledWith({ + name: VIEWS.PROJECTS_WORKFLOWS, + params: { projectId: workflow.homeProject?.id ?? '' }, + }); + }); + + it('should display "-" when no project exists', () => { + const workflow = mockWorkflow('1', { + homeProject: undefined, + }); + + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const noProject = getByTestId('mcp-workflow-no-project'); + expect(noProject).toHaveTextContent('-'); + }); + }); + + describe('User interactions and events', () => { + it('should emit refresh event when refresh button is clicked', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + workflows: [mockWorkflow('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-workflows-refresh-button'); + await userEvent.click(refreshButton); + + expect(emitted()).toHaveProperty('refresh'); + expect(emitted().refresh).toHaveLength(1); + }); + + it('should emit removeMcpAccess event when action is selected', async () => { + const workflow = mockWorkflow('1'); + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const actionToggle = getByTestId('mcp-workflow-action-toggle'); + expect(actionToggle).toBeInTheDocument(); + + const actionButton = actionToggle?.querySelector('[role=button]'); + if (!actionButton) { + throw new Error('Action button not found'); + } + + await userEvent.click(actionButton); + + const actionToggleId = actionButton.getAttribute('aria-controls'); + const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement; + expect(actionDropdown).toBeInTheDocument(); + + const removeAction = actionDropdown.querySelector('[data-test-id="action-removeFromMCP"]'); + expect(removeAction).toBeInTheDocument(); + await userEvent.click(removeAction!); + + expect(emitted()).toHaveProperty('removeMcpAccess'); + expect(emitted().removeMcpAccess).toHaveLength(1); + expect(emitted().removeMcpAccess[0]).toEqual([workflow]); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue new file mode 100644 index 00000000000..3635c57eb67 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue new file mode 100644 index 00000000000..227560927ce --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/ConnectionParameter.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/ConnectionParameter.vue new file mode 100644 index 00000000000..171a2399ea9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/ConnectionParameter.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.test.ts new file mode 100644 index 00000000000..d676d43cad2 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.test.ts @@ -0,0 +1,242 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import OAuthClientsTable from './OAuthClientsTable.vue'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import type { OAuthClientResponseDto } from '@n8n/api-types'; + +const renderComponent = createComponentRenderer(OAuthClientsTable); + +let pinia: ReturnType; + +const mockOAuthClient = ( + id: string, + overrides?: Partial, +): OAuthClientResponseDto => ({ + id, + name: `OAuth Client ${id}`, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + redirectUris: [], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'client_secret_post', + ...overrides, +}); + +describe('OAuthClientsTable', () => { + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + vi.clearAllMocks(); + }); + + describe('Component rendering', () => { + it('should render loading state correctly', () => { + const { container, getByTestId } = renderComponent({ + pinia, + props: { + clients: [], + loading: true, + }, + }); + + const loadingElements = container.querySelectorAll('.n8n-loading'); + expect(loadingElements.length).toBeGreaterThan(0); + + expect(() => getByTestId('oauth-clients-data-table')).toThrow(); + expect(() => getByTestId('empty-workflow-list-box')).toThrow(); + }); + + it('should render empty state when no clients exist', () => { + const { getByTestId, getByText } = renderComponent({ + pinia, + props: { + clients: [], + loading: false, + }, + }); + + expect(getByTestId('empty-oauth-clients-list-box')).toBeInTheDocument(); + expect(getByText('Connected oAuth clients (0)')).toBeInTheDocument(); + }); + + it('should render clients table with correct data', () => { + const clients = [mockOAuthClient('1'), mockOAuthClient('2', { name: 'Second Client' })]; + + const { getByTestId, getByText } = renderComponent({ + pinia, + props: { + clients, + loading: false, + }, + }); + + const table = getByTestId('oauth-clients-data-table'); + expect(table).toBeInTheDocument(); + + expect(getByText(`Connected oAuth clients (${clients.length})`)).toBeInTheDocument(); + + clients.forEach((client) => { + expect(getByText(client.name)).toBeInTheDocument(); + }); + }); + + it('should render refresh button', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + clients: [mockOAuthClient('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-oauth-clients-refresh-button'); + expect(refreshButton).toBeInTheDocument(); + }); + }); + + describe('Table data display', () => { + it('should display client name correctly', () => { + const client = mockOAuthClient('1', { name: 'Test OAuth Client' }); + const { getByText } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + expect(getByText(client.name)).toBeInTheDocument(); + }); + + it('should display created at date', () => { + const client = mockOAuthClient('1', { + createdAt: '2024-01-01T12:00:00.000Z', + }); + const { getByTestId } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + const createdAtElement = getByTestId('mcp-client-created-at'); + expect(createdAtElement).toBeInTheDocument(); + }); + + it('should display action toggle for each client', () => { + const clients = [mockOAuthClient('1'), mockOAuthClient('2')]; + const { getAllByTestId } = renderComponent({ + pinia, + props: { + clients, + loading: false, + }, + }); + + const actionToggles = getAllByTestId('mcp-oauth-client-action-toggle'); + expect(actionToggles).toHaveLength(clients.length); + }); + }); + + describe('User interactions and events', () => { + it('should emit refresh event when refresh button is clicked', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + clients: [mockOAuthClient('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-oauth-clients-refresh-button'); + await userEvent.click(refreshButton); + + expect(emitted()).toHaveProperty('refresh'); + expect(emitted().refresh).toHaveLength(1); + }); + + it('should emit revokeClient event when action is selected', async () => { + const client = mockOAuthClient('1'); + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + const actionToggle = getByTestId('mcp-oauth-client-action-toggle'); + expect(actionToggle).toBeInTheDocument(); + + const actionButton = actionToggle?.querySelector('[role=button]'); + if (!actionButton) { + throw new Error('Action button not found'); + } + + await userEvent.click(actionButton); + + const actionToggleId = actionButton.getAttribute('aria-controls'); + const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement; + expect(actionDropdown).toBeInTheDocument(); + + const revokeAction = actionDropdown.querySelector('[data-test-id="action-revokeClient"]'); + expect(revokeAction).toBeInTheDocument(); + await userEvent.click(revokeAction!); + + expect(emitted()).toHaveProperty('revokeClient'); + expect(emitted().revokeClient).toHaveLength(1); + expect(emitted().revokeClient[0]).toEqual([client]); + }); + + it('should not emit events when loading', () => { + const { queryByTestId, emitted } = renderComponent({ + pinia, + props: { + clients: [mockOAuthClient('1')], + loading: true, + }, + }); + + const actionToggle = queryByTestId('mcp-oauth-client-action-toggle'); + expect(actionToggle).not.toBeInTheDocument(); + + expect(emitted()).not.toHaveProperty('revokeClient'); + expect(emitted()).not.toHaveProperty('refresh'); + }); + }); + + describe('Edge cases', () => { + it('should handle empty client name gracefully', () => { + const client = mockOAuthClient('1', { name: '' }); + const { container } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + const table = container.querySelector('[data-test-id="oauth-clients-data-table"]'); + expect(table).toBeInTheDocument(); + }); + + it('should handle large number of clients', () => { + const clients = Array.from({ length: 50 }, (_, i) => + mockOAuthClient(`${i + 1}`, { name: `Client ${i + 1}` }), + ); + + const { getByText, getByTestId } = renderComponent({ + pinia, + props: { + clients, + loading: false, + }, + }); + + expect(getByText('Connected oAuth clients (50)')).toBeInTheDocument(); + expect(getByTestId('oauth-clients-data-table')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.vue new file mode 100644 index 00000000000..4dba004598e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue new file mode 100644 index 00000000000..b2cdf8befb5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts index 3826f05fce2..49a0c44c398 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts @@ -1,4 +1,8 @@ -import type { ApiKey } from '@n8n/api-types'; +import type { + ApiKey, + ListOAuthClientsResponseDto, + DeleteOAuthClientResponseDto, +} from '@n8n/api-types'; import type { IWorkflowSettings } from '@/Interface'; import type { IRestApiContext } from '@n8n/rest-api-client'; import { makeRestApiRequest } from '@n8n/rest-api-client'; @@ -42,3 +46,20 @@ export async function toggleWorkflowMcpAccessApi( }, ); } + +export async function fetchOAuthClients( + context: IRestApiContext, +): Promise { + return await makeRestApiRequest(context, 'GET', '/mcp/oauth-clients'); +} + +export async function deleteOAuthClient( + context: IRestApiContext, + clientId: string, +): Promise { + return await makeRestApiRequest( + context, + 'DELETE', + `/mcp/oauth-clients/${encodeURIComponent(clientId)}`, + ); +} diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts index 44ace857702..c1aac5ff3a8 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts @@ -8,11 +8,13 @@ import { toggleWorkflowMcpAccessApi, fetchApiKey, rotateApiKey, + fetchOAuthClients, + deleteOAuthClient, } from '@/features/ai/mcpAccess/mcp.api'; import { computed, ref } from 'vue'; import { useSettingsStore } from '@/app/stores/settings.store'; import { isWorkflowListItem } from '@/app/utils/typeGuards'; -import type { ApiKey } from '@n8n/api-types'; +import type { ApiKey, OAuthClientResponseDto, DeleteOAuthClientResponseDto } from '@n8n/api-types'; export const useMCPStore = defineStore(MCP_STORE, () => { const workflowsStore = useWorkflowsStore(); @@ -20,6 +22,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => { const settingsStore = useSettingsStore(); const currentUserMCPKey = ref(null); + const oauthClients = ref([]); const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled); @@ -97,6 +100,19 @@ export const useMCPStore = defineStore(MCP_STORE, () => { return apiKey; } + async function getAllOAuthClients(): Promise { + const response = await fetchOAuthClients(rootStore.restApiContext); + oauthClients.value = response.data; + return response.data; + } + + async function removeOAuthClient(clientId: string): Promise { + const response = await deleteOAuthClient(rootStore.restApiContext, clientId); + // Remove the client from the local store + oauthClients.value = oauthClients.value.filter((client) => client.id !== clientId); + return response; + } + return { mcpAccessEnabled, fetchWorkflowsAvailableForMCP, @@ -105,5 +121,8 @@ export const useMCPStore = defineStore(MCP_STORE, () => { currentUserMCPKey, getOrCreateApiKey, generateNewApiKey, + oauthClients, + getAllOAuthClients, + removeOAuthClient, }; }); From 8504beb154ef6ec2e6892cca851b0efdae366ddb Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 6 Nov 2025 10:28:06 -0500 Subject: [PATCH 13/50] fix(core): Column size for token column (#21609) --- .../src/migrations/common/1760116750277-CreateOAuthEntities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts b/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts index 9f4a67f7012..6a1bf79e3fa 100644 --- a/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts +++ b/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts @@ -43,7 +43,7 @@ export class CreateOAuthEntities1760116750277 implements ReversibleMigration { // Create oauth_access_tokens table await createTable('oauth_access_tokens') .withColumns( - column('token').varchar(255).primary.notNull, + column('token').varchar().primary.notNull, column('clientId').varchar().notNull, column('userId').uuid.notNull, ) From c1007367458f1b0554c4f2b00f6fd907ef23d000 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 6 Nov 2025 16:41:22 +0100 Subject: [PATCH 14/50] fix(core): Insights fix same day queries (#21574) --- .../insights-by-period-query.helper.test.ts | 591 ++++++++++-------- .../insights-by-period-query.helper.ts | 157 ++--- 2 files changed, 361 insertions(+), 387 deletions(-) diff --git a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts index 11150e4f5cf..601c90e5e07 100644 --- a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts @@ -1,86 +1,10 @@ import type { DatabaseConfig } from '@n8n/config'; import { DateTime } from 'luxon'; -import { getDateRangesCommonTableExpressionQuery } from '../insights-by-period-query.helper'; - -function expectLastXDaysDateRangeQuery(params: { - result: string; - dbType: DatabaseConfig['type']; - prevStartDateOffset: number; - startDateOffset: number; -}) { - const { result, dbType, prevStartDateOffset: prev, startDateOffset: start } = params; - - if (dbType === 'sqlite') { - if (prev === 0) { - expect(result).toContain("datetime('now') AS prev_start_date"); - } else { - expect(result).toContain(`datetime('now', '-${prev} days') AS prev_start_date`); - } - if (start === 0) { - expect(result).toContain("datetime('now') AS start_date"); - } else { - expect(result).toContain(`datetime('now', '-${start} days') AS start_date`); - } - expect(result).toContain("datetime('now') AS end_date"); - } else if (dbType === 'postgresdb') { - if (prev === 0) { - expect(result).toContain('NOW() AS prev_start_date'); - } else { - expect(result).toContain(`NOW() - INTERVAL '${prev} days' AS prev_start_date`); - } - if (start === 0) { - expect(result).toContain('NOW() AS start_date'); - } else { - expect(result).toContain(`NOW() - INTERVAL '${start} days' AS start_date`); - } - expect(result).toContain('NOW() AS end_date'); - } else { - if (prev === 0) { - expect(result).toContain('NOW() AS prev_start_date'); - } else { - expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${prev} DAY) AS prev_start_date`); - } - if (start === 0) { - expect(result).toContain('NOW() AS start_date'); - } else { - expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${start} DAY) AS start_date`); - } - expect(result).toContain('NOW() AS end_date'); - } -} - -function expectStartOfDayDateRangeQuery(params: { - result: string; - dbType: DatabaseConfig['type']; - prevStartDateOffset: number; - startDateOffset: number; - endDateOffset: number; -}) { - const { - result, - dbType, - prevStartDateOffset: prev, - startDateOffset: start, - endDateOffset: end, - } = params; - - if (dbType === 'sqlite') { - expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`); - expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`); - expect(result).toContain(`datetime('now', '-${end} days', 'start of day') AS end_date`); - } else if (dbType === 'postgresdb') { - expect(result).toContain( - `DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`, - ); - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`); - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${end} days') AS end_date`); - } else { - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`); - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`); - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${end} DAY)) AS end_date`); - } -} +import { + getDateRangesCommonTableExpressionQuery, + getDateRangesSelectQuery, +} from '../insights-by-period-query.helper'; describe('getDateRangesCommonTableExpressionQuery', () => { const now = DateTime.utc(2025, 10, 8, 8, 51, 27); @@ -105,63 +29,83 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); - if (dbType === 'sqlite') { - expect(result).toContain("datetime('now', '-2 days')"); // prev_start_date - expect(result).toContain("datetime('now', '-1 days')"); // start_date - expect(result).toContain("datetime('now')"); // end_date - } else if (dbType === 'postgresdb') { - expect(result).toContain("NOW() - INTERVAL '2 days'"); // prev_start_date - expect(result).toContain("NOW() - INTERVAL '1 days'"); // start_date - expect(result).toContain('NOW()'); // end_date - } else { - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY)'); // prev_start_date - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY)'); // start_date - expect(result).toContain('NOW()'); // end_date - } + // endDate is today but different day from startDate, so dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 2 }).startOf('day'), + startDateTime: now.minus({ days: 1 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('day before yesterday (specific day)', () => { const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 3, - startDateOffset: 2, - endDateOffset: 1, }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 4 }).startOf('day'), + startDateTime: now.minus({ days: 2 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('7 days ago (specific day)', () => { const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 8, - startDateOffset: 7, - endDateOffset: 6, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 9 }).startOf('day'), + startDateTime: now.minus({ days: 7 }).startOf('day'), + endDateTime: now.minus({ days: 5 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('14 days ago (specific day)', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 13 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 15, - startDateOffset: 14, - endDateOffset: 13, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 16 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.minus({ days: 12 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('X days ago (specific day far in the past)', () => { @@ -169,14 +113,21 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 109 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 108 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 110, - startDateOffset: 109, - endDateOffset: 108, }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 111 }).startOf('day'), + startDateTime: now.minus({ days: 109 }).startOf('day'), + endDateTime: now.minus({ days: 107 }).startOf('day'), + }); + expect(result).toBe(expected); }); }); @@ -186,39 +137,60 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 14, // 7 + 7 - startDateOffset: 7, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 14 }).startOf('day'), + startDateTime: now.minus({ days: 7 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 14 days', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 28, // 14 + 14 - startDateOffset: 14, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 28 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 30 days', () => { const startDate = now.minus({ days: 30 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 60, // 30 + 30 - startDateOffset: 30, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 60 }).startOf('day'), + startDateTime: now.minus({ days: 30 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); }); @@ -227,70 +199,100 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 3 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 5, - startDateOffset: 3, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 3 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 6 }).startOf('day'), + startDateTime: now.minus({ days: 3 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('5 days range', () => { const startDate = now.minus({ days: 10 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 5 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 15, - startDateOffset: 10, - endDateOffset: 5, }); + + // Past range: end+1 day, duration = 6 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 16 }).startOf('day'), + startDateTime: now.minus({ days: 10 }).startOf('day'), + endDateTime: now.minus({ days: 4 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('7 days range', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 21, - startDateOffset: 14, - endDateOffset: 7, }); + + // Past range: end+1 day, duration = 8 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 22 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.minus({ days: 6 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('14 days range', () => { const startDate = now.minus({ days: 15 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 29, - startDateOffset: 15, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 15 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 30 }).startOf('day'), + startDateTime: now.minus({ days: 15 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('30 days range', () => { const startDate = now.minus({ days: 53 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 23 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 83, - startDateOffset: 53, - endDateOffset: 23, }); + + // Past range: end+1 day, duration = 31 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 84 }).startOf('day'), + startDateTime: now.minus({ days: 53 }).startOf('day'), + endDateTime: now.minus({ days: 22 }).startOf('day'), + }); + expect(result).toBe(expected); }); }); }); @@ -301,43 +303,68 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 90 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 180, - startDateOffset: 90, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 180 }).startOf('day'), + startDateTime: now.minus({ days: 90 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 6 months', () => { const startDate = now.minus({ months: 6 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2; - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: prevDaysBack, - startDateOffset: daysBack, }); + + const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day'); + const endDateTime = now.startOf('day'); + const duration = endDateTime.diff(startDateTime); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); test('last year', () => { const startDate = now.minus({ years: 1 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2; - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: prevDaysBack, - startDateOffset: daysBack, }); + + const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day'); + const endDateTime = now.startOf('day'); + const duration = endDateTime.diff(startDateTime); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); }); @@ -346,56 +373,80 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 32 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 63, - startDateOffset: 32, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 32 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 64 }).startOf('day'), + startDateTime: now.minus({ days: 32 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('90 days range (specific historical range)', () => { const startDate = now.minus({ days: 98 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 8 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 188, - startDateOffset: 98, - endDateOffset: 8, }); + + // Past range: end+1 day, duration = 91 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 189 }).startOf('day'), + startDateTime: now.minus({ days: 98 }).startOf('day'), + endDateTime: now.minus({ days: 7 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('180 days range (specific historical range)', () => { const startDate = now.minus({ days: 181 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 361, - startDateOffset: 181, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 181 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 362 }).startOf('day'), + startDateTime: now.minus({ days: 181 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('360 days range (specific historical range)', () => { const startDate = now.minus({ days: 361 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 721, - startDateOffset: 361, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 361 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 722 }).startOf('day'), + startDateTime: now.minus({ days: 361 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); }); }); @@ -403,90 +454,78 @@ describe('getDateRangesCommonTableExpressionQuery', () => { describe('edge cases', () => { test('handles date with time component correctly', () => { // Oct 6 14:30 to Oct 7 18:45 - // Now is Oct 8 8:51:27, so Oct 7 18:45 is less than 1 full day ago - // Therefore useStartOfDay = false (not yet a full day in the past) - // daysFromEndDateToToday = Math.round(0.58) = 1 - // daysDiff = Math.round(1.18) = 1 - // daysFromStartDateToToday = Math.floor(1.76) = 1 - // prevStartDaysFromToday = 1 + 1 = 2 + // Now is Oct 8 8:51:27, so Oct 7 18:45 is in the past const startDate = DateTime.utc(2025, 10, 6, 14, 30, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 18, 45, 30).toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); - // Verify the actual output based on the calculation - if (dbType === 'sqlite') { - expect(result).toContain("datetime('now', '-2 days') AS prev_start_date"); - expect(result).toContain("datetime('now', '-1 days') AS start_date"); - expect(result).toContain("datetime('now', '-1 days') AS end_date"); - } else if (dbType === 'postgresdb') { - expect(result).toContain("NOW() - INTERVAL '2 days' AS prev_start_date"); - expect(result).toContain("NOW() - INTERVAL '1 days' AS start_date"); - expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date"); - } else { - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY) AS prev_start_date'); - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS start_date'); - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date'); - } + // Past range, take full days + const startDateTime = DateTime.utc(2025, 10, 6, 14, 30, 0).startOf('day'); + const endDateTime = DateTime.utc(2025, 10, 7, 18, 45, 30).plus({ days: 1 }).startOf('day'); + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); test('handles same day with different times correctly (hour periodicity)', () => { // Oct 7 9:00 to Oct 7 17:00 (same day) - // Now is Oct 8 8:51:27, so Oct 7 17:00 is less than 1 full day ago - // useStartOfDay = false - // daysDiff = 0 (same day), daysFromEndDateToToday = 1 (rounded) - // daysFromStartDateToToday = 0 (floored), prevStartDaysFromToday = 0 + 0 = 0 + // Now is Oct 8 8:51:27 const startDate = DateTime.utc(2025, 10, 7, 9, 0, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 17, 0, 0).toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - - // Verify the actual output based on the calculation - if (dbType === 'sqlite') { - expect(result).toContain("datetime('now') AS prev_start_date"); - expect(result).toContain("datetime('now') AS start_date"); - expect(result).toContain("datetime('now', '-1 days') AS end_date"); - } else if (dbType === 'postgresdb') { - expect(result).toContain('NOW() AS prev_start_date'); - expect(result).toContain('NOW() AS start_date'); - expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date"); - } else { - expect(result).toContain('NOW() AS prev_start_date'); - expect(result).toContain('NOW() AS start_date'); - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date'); - } - }); - - test('handles daylight saving time transition correctly', () => { - // Simulate DST transition: Oct 22 (GMT+0200) to Nov 5 (GMT+0100) - // Same wall-clock time but different timezone offset - // Oct 26 2025 is when DST ends in Europe (clocks go back 1 hour) - const startDate = DateTime.fromObject( - { year: 2025, month: 10, day: 22, hour: 12, minute: 37, second: 56 }, - { zone: 'Europe/Paris' }, - ).toJSDate(); - const endDate = DateTime.fromObject( - { year: 2025, month: 11, day: 5, hour: 12, minute: 37, second: 56 }, - { zone: 'Europe/Paris' }, - ).toJSDate(); - - // Mock current time to be Nov 5 - jest.setSystemTime(endDate); - - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - - // With DST normalization: Oct 22 to Nov 5 is 14 calendar days - // The function detects same wall-clock time but different timezone offset - // and normalizes to calculate correct calendar days - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 28, // 14 + 14 - startDateOffset: 14, }); - // Restore original mock time - jest.setSystemTime(now.toJSDate()); + // Past range, take full days + const startDateTime = DateTime.utc(2025, 10, 7, 9, 0, 0).startOf('day'); + const endDateTime = DateTime.utc(2025, 10, 7, 17, 0, 0).plus({ days: 1 }).startOf('day'); + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); + }); + + test('handle current day as both start and end date', () => { + const startDate = now.toJSDate(); + const endDate = now.toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); + + // startDate and endDate are today, so start is startOf('day'), end is now + const startDateTime = now.startOf('day'); + const endDateTime = now; + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); }); }); diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts index 2f3bfd8a2df..3b90c7d5894 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts @@ -2,148 +2,83 @@ import type { DatabaseConfig } from '@n8n/config'; import { sql } from '@n8n/db'; import { DateTime } from 'luxon'; -/** - * Generates database-specific SQL for a datetime value relative to now - * @param dbType - The database type - * @param daysFromToday - Number of days back from today (0 = now) - * @param useStartOfDay - Whether to truncate to start of day (00:00:00) - */ -const getDatetimeSql = ({ - dbType, - daysFromToday, - useStartOfDay = false, -}: { - dbType: DatabaseConfig['type']; - daysFromToday: number; - useStartOfDay?: boolean; -}): string => { - // Handle "now" case - if (daysFromToday === 0 && !useStartOfDay) { - return dbType === 'sqlite' ? "datetime('now')" : 'NOW()'; - } - - // SQLite - if (dbType === 'sqlite') { - if (daysFromToday === 0 && useStartOfDay) { - return "datetime('now', 'start of day')"; - } - if (useStartOfDay) { - return `datetime('now', '-${daysFromToday} days', 'start of day')`; - } - return `datetime('now', '-${daysFromToday} days')`; - } - - // PostgreSQL - if (dbType === 'postgresdb') { - if (daysFromToday === 0 && useStartOfDay) { - return "DATE_TRUNC('day', NOW())"; - } - if (useStartOfDay) { - return `DATE_TRUNC('day', NOW() - INTERVAL '${daysFromToday} days')`; - } - return `NOW() - INTERVAL '${daysFromToday} days'`; - } - - // MySQL/MariaDB - if (daysFromToday === 0 && useStartOfDay) { - return 'DATE(NOW())'; - } - if (useStartOfDay) { - return `DATE(DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY))`; - } - return `DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY)`; -}; - /** * Generates a SQL Common Table Expression (CTE) query that provides three date boundaries for insights queries * * Behavior: - * - If startDate and endDate are the same and today - * - returns the last 24 hours: prev_start_date (2 days ago), start_date (1 day ago), end_date (now). - * - Otherwise: - * - prev_start_date: start of the day before the range - * - start_date: start of the current range - * - end_date: "now" if endDate is today, else start of the day after endDate + * - If the end date is today and the start date is also today, start date is set to the start of the day to take today's data. + * - If the end date is in the past, both start and end dates are set to the start of their respective days, to take full days. * * The SQL CTE can be joined with the insights table for filtering/aggregation. * - * @param dbType - The database type ('sqlite', 'postgresdb', 'mysqldb', 'mariadb') * @param startDate - The start date of the range (inclusive) * @param endDate - The end date of the range (inclusive, or "now" if today) + * @param dbType - The database type (postgresdb, mysqldb, mariadb, or sqlite) * @returns SQL CTE query with `prev_start_date`, `start_date`, and `end_date` columns * - `prev_start_date`: The start of the previous period (used for comparison) * - `start_date`: The start of the current period (inclusive) * - `end_date`: The end of the current period (exclusive) */ export const getDateRangesCommonTableExpressionQuery = ({ - dbType, startDate, endDate, + dbType, }: { - dbType: DatabaseConfig['type']; startDate: Date; endDate: Date; + dbType: DatabaseConfig['type']; }) => { - let today = DateTime.now(); - let startDateTime = DateTime.fromJSDate(startDate); - let endDateTime = DateTime.fromJSDate(endDate); + let startDateTime = DateTime.fromJSDate(startDate).toUTC(); + let endDateTime = DateTime.fromJSDate(endDate).toUTC(); - // If the end date is in a past day, use start of day for both dates - const useStartOfDay = today.diff(endDateTime, 'days').days >= 1; + const today = DateTime.now().toUTC(); + const isEndDateToday = endDateTime.hasSame(today, 'day'); - if (useStartOfDay) { + // Past range, take full days + if (!isEndDateToday) { startDateTime = startDateTime.startOf('day'); - endDateTime = endDateTime.startOf('day'); - today = today.startOf('day'); + endDateTime = endDateTime.plus({ days: 1 }).startOf('day'); } - // Check if times are exactly the same but timezone differs (DST transition case) - const offsetDiff = Math.abs(startDateTime.offset - endDateTime.offset); - - // If same wall-clock time but different timezone offset (max 2 hours), normalize to same timezone - if ( - startDateTime.hour === endDateTime.hour && - startDateTime.minute === endDateTime.minute && - startDateTime.second === endDateTime.second && - offsetDiff > 0 && - offsetDiff <= 120 // Max 2 hours difference in minutes - ) { - // Change the startDateTime to so that time matches - startDateTime = startDateTime.plus({ minutes: offsetDiff }); + // Today range, take all day data starting from the beginning of the day + if (isEndDateToday && startDateTime.hasSame(endDateTime, 'day')) { + startDateTime = startDateTime.startOf('day'); } - // Convert to UTC to avoid DST issues when calculating day differences - const startDateTimeUTC = startDateTime.toUTC(); - const endDateTimeUTC = endDateTime.toUTC(); - const todayUTC = today.toUTC(); + const prevStartDateTime = startDateTime.minus(endDateTime.diff(startDateTime)); - const daysFromEndDateToToday = Math.round(todayUTC.diff(endDateTimeUTC, 'days').days); - const daysDiff = Math.round(endDateTimeUTC.diff(startDateTimeUTC, 'days').days); + return getDateRangesSelectQuery({ dbType, prevStartDateTime, startDateTime, endDateTime }); +}; - const daysFromStartDateToToday = Math.floor(todayUTC.diff(startDateTimeUTC, 'days').days); - const prevStartDaysFromToday = daysFromStartDateToToday + daysDiff; +export function getDateRangesSelectQuery({ + dbType, + prevStartDateTime, + startDateTime, + endDateTime, +}: { + dbType: DatabaseConfig['type']; + prevStartDateTime: DateTime; + startDateTime: DateTime; + endDateTime: DateTime; +}) { + const prevStartStr = prevStartDateTime.toSQL({ includeZone: false, includeOffset: false }); + const startStr = startDateTime.toSQL({ includeZone: false, includeOffset: false }); + const endStr = endDateTime.toSQL({ includeZone: false, includeOffset: false }); - const prevStartDateSql = getDatetimeSql({ - dbType, - daysFromToday: prevStartDaysFromToday, - useStartOfDay, - }); - - const startDateSql = getDatetimeSql({ - dbType, - daysFromToday: daysFromStartDateToToday, - useStartOfDay, - }); - - const endDateSql = getDatetimeSql({ - dbType, - daysFromToday: daysFromEndDateToToday, - useStartOfDay, - }); + // Database-specific timestamp casting + // PostgreSQL requires explicit CAST or :: syntax for timestamp comparisons + // SQLite and MySQL/MariaDB can work with string literals in comparisons + if (dbType === 'postgresdb') { + return sql`SELECT + CAST('${prevStartStr}' AS TIMESTAMP) AS prev_start_date, + CAST('${startStr}' AS TIMESTAMP) AS start_date, + CAST('${endStr}' AS TIMESTAMP) AS end_date + `; + } return sql`SELECT - ${prevStartDateSql} AS prev_start_date, - ${startDateSql} AS start_date, - ${endDateSql} AS end_date + '${prevStartStr}' AS prev_start_date, + '${startStr}' AS start_date, + '${endStr}' AS end_date `; -}; +} From e6db2f17a5d40733450f96d8c929140c2ab6909f Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Fri, 7 Nov 2025 01:00:09 +0200 Subject: [PATCH 15/50] test: Use pooled sqlite driver in cli tests (#21553) --- packages/cli/package.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 23aa86e0507..b6dbf8a5568 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,17 +20,17 @@ "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", - "test": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest", - "test:unit": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.unit.js", - "test:integration": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.integration.js", - "test:dev": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --watch", - "test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage", + "test": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest", + "test:unit": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.unit.js", + "test:integration": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js", + "test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch", + "test:sqlite": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage", "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage", "test:mariadb": "N8N_LOG_LEVEL=silent DB_TYPE=mariadb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage", "test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage --maxWorkers=1", - "test:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest", - "test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest --watch", - "test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js", + "test:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest", + "test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --watch", + "test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js", "test:postgres:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=postgresdb&& set DB_POSTGRESDB_SCHEMA=alt_schema&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", "test:mariadb:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mariadb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", "test:mysql:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mysqldb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", From ecc67062a435a1924efbc0f6be7a03d4076fae19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Fri, 7 Nov 2025 08:49:57 +0100 Subject: [PATCH 16/50] feat(core): Add workflow descriptions (#21526) Co-authored-by: Ricardo Espinoza --- packages/@n8n/db/src/entities/types-db.ts | 9 +- .../@n8n/db/src/entities/workflow-entity.ts | 3 + ...2177736257-AddWorkflowDescriptionColumn.ts | 11 + .../@n8n/db/src/migrations/mysqldb/index.ts | 2 + .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + .../src/repositories/workflow.repository.ts | 46 +- .../list-query/__tests__/list-query.test.ts | 18 +- .../list-query/dtos/workflow.filter.dto.ts | 2 +- .../list-query/dtos/workflow.select.dto.ts | 1 + .../__tests__/search-workflows.tool.test.ts | 8 +- packages/cli/src/modules/mcp/mcp.types.ts | 3 +- .../mcp/tools/search-workflows.tool.ts | 21 +- .../cli/src/workflows/workflow.request.ts | 1 + .../cli/src/workflows/workflow.service.ts | 1 + .../workflows/workflows.controller.test.ts | 25 +- .../src/components/N8nIcon/icons.ts | 2 + .../frontend/@n8n/i18n/src/locales/en.json | 9 +- .../@n8n/rest-api-client/src/api/workflows.ts | 1 + packages/frontend/editor-ui/src/Interface.ts | 3 + .../app/components/MainHeader/MainHeader.vue | 1 + .../WorkflowDescriptionPopover.test.ts | 731 ++++++++++++++++++ .../MainHeader/WorkflowDescriptionPopover.vue | 252 ++++++ .../components/MainHeader/WorkflowDetails.vue | 27 +- .../src/app/components/WorkflowCard.vue | 37 +- .../app/components/WorkflowSettings.test.ts | 2 +- .../src/app/components/WorkflowSettings.vue | 35 +- .../src/app/composables/useWorkflowHelpers.ts | 1 + .../src/app/stores/workflows.store.ts | 56 +- .../src/app/views/WorkflowsView.test.ts | 2 +- .../editor-ui/src/app/views/WorkflowsView.vue | 3 +- .../mcpAccess/components/WorkflowsTable.vue | 35 + .../useWorkflowResourcesLocator.test.ts | 2 +- .../useWorkflowResourcesLocator.ts | 2 +- .../useWorkflowNavigationCommands.test.ts | 4 +- .../useWorkflowNavigationCommands.ts | 30 +- packages/workflow/src/interfaces.ts | 1 + 37 files changed, 1295 insertions(+), 96 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1762177736257-AddWorkflowDescriptionColumn.ts create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 0b65b2f8952..b9782f38394 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -217,7 +217,14 @@ export namespace ListQueryDb { * Slim workflow returned from a list query operation. */ export namespace Workflow { - type OptionalBaseFields = 'name' | 'active' | 'versionId' | 'createdAt' | 'updatedAt' | 'tags'; + type OptionalBaseFields = + | 'name' + | 'active' + | 'versionId' + | 'createdAt' + | 'updatedAt' + | 'tags' + | 'description'; type BaseFields = Pick & Partial>; diff --git a/packages/@n8n/db/src/entities/workflow-entity.ts b/packages/@n8n/db/src/entities/workflow-entity.ts index 7d1469e3fa3..026a0efeb12 100644 --- a/packages/@n8n/db/src/entities/workflow-entity.ts +++ b/packages/@n8n/db/src/entities/workflow-entity.ts @@ -32,6 +32,9 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl @Column({ length: 128 }) name: string; + @Column({ type: 'text', nullable: true }) + description: string | null; + @Column() active: boolean; diff --git a/packages/@n8n/db/src/migrations/common/1762177736257-AddWorkflowDescriptionColumn.ts b/packages/@n8n/db/src/migrations/common/1762177736257-AddWorkflowDescriptionColumn.ts new file mode 100644 index 00000000000..b59338d2bb3 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1762177736257-AddWorkflowDescriptionColumn.ts @@ -0,0 +1,11 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddWorkflowDescriptionColumn1762177736257 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('workflow_entity', [column('description').text]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('workflow_entity', ['description']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index 44ac9066c1b..3fdab884fad 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -107,6 +107,7 @@ import { CreateChatHubTables1760019379982 } from '../common/1760019379982-Create import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable'; import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; +import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import type { Migration } from '../migration-types'; import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; @@ -222,5 +223,6 @@ export const mysqlMigrations: Migration[] = [ DropUnusedChatHubColumns1760965142113, AddWorkflowVersionColumn1761047826451, ChangeDependencyInfoToJson1761655473000, + AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index f08678f5639..cc31b66df81 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -108,6 +108,7 @@ import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-Cr import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; +import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -220,5 +221,6 @@ export const postgresMigrations: Migration[] = [ DropUnusedChatHubColumns1760965142113, AddWorkflowVersionColumn1761047826451, ChangeDependencyInfoToJson1761655473000, + AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 1816873044a..4709265d352 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -106,6 +106,7 @@ import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-Create import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; import { AddAudienceColumnToApiKeys1758731786132 } from './../common/1758731786132-AddAudienceColumnToApiKey'; +import { AddWorkflowDescriptionColumn1762177736257 } from './../common/1762177736257-AddWorkflowDescriptionColumn'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -214,6 +215,7 @@ const sqliteMigrations: Migration[] = [ DropUnusedChatHubColumns1760965142113, AddWorkflowVersionColumn1761047826451, ChangeDependencyInfoToJson1761655473000, + AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, ]; diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 1326d023a32..a07e3f8efec 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -147,8 +147,29 @@ export class WorkflowRepository extends Repository { } private buildBaseUnionQuery(workflowIds: string[], options: ListQuery.Options = {}) { - const subQueryParameters: ListQuery.Options = { + // Common fields for both folders and workflows + const commonFields = { + createdAt: true, + updatedAt: true, + id: true, + name: true, + } as const; + + // Transform `query` => `name` for folder repository + const folderFilter = options.filter ? { ...options.filter } : undefined; + if (folderFilter?.query) { + folderFilter.name = folderFilter.query; + } + + const folderQueryParameters: ListQuery.Options = { + select: commonFields, + filter: folderFilter, + }; + + const workflowQueryParameters: ListQuery.Options = { select: { + ...commonFields, + description: true, // For some reason the order of updatedAt and createdAt here is load-bearing // and the generated sql queries below risk switching up the order otherwise // depending on whether this code is called for a project or the overview @@ -163,17 +184,19 @@ export class WorkflowRepository extends Repository { filter: options.filter, }; - const columnNames = [...Object.keys(subQueryParameters.select ?? {}), 'resource']; + // For union, we need to have the same columns, so add NULL as description for folders + const columnNames = [...Object.keys(workflowQueryParameters.select ?? {}), 'resource']; const [sortByColumn, sortByDirection] = this.parseSortingParams( options.sortBy ?? 'updatedAt:asc', ); const foldersQuery = this.folderRepository - .getManyQuery(subQueryParameters) + .getManyQuery(folderQueryParameters) + .addSelect('NULL', 'description') // Add NULL for description in folders .addSelect("'folder'", 'resource'); - const workflowsQuery = this.getManyQuery(workflowIds, subQueryParameters).addSelect( + const workflowsQuery = this.getManyQuery(workflowIds, workflowQueryParameters).addSelect( "'workflow'", 'resource', ); @@ -296,7 +319,7 @@ export class WorkflowRepository extends Repository { typeof options.filter?.parentFolderId === 'string' && options.filter.parentFolderId !== PROJECT_ROOT && typeof options.filter?.projectId === 'string' && - options.filter.name + options.filter.query ) { const folderIds = await this.folderRepository.getAllFolderIdsInHierarchy( options.filter.parentFolderId, @@ -478,10 +501,14 @@ export class WorkflowRepository extends Repository { qb: SelectQueryBuilder, filter: ListQuery.Options['filter'], ): void { - if (typeof filter?.name === 'string' && filter.name !== '') { - qb.andWhere('LOWER(workflow.name) LIKE :name', { - name: `%${filter.name.toLowerCase()}%`, - }); + const searchValue = filter?.query; + + if (typeof searchValue === 'string' && searchValue !== '') { + const searchTerm = `%${searchValue.toLowerCase()}%`; + qb.andWhere( + "(LOWER(workflow.name) LIKE :searchTerm OR LOWER(COALESCE(workflow.description, '')) LIKE :searchTerm)", + { searchTerm }, + ); } } @@ -613,6 +640,7 @@ export class WorkflowRepository extends Repository { 'workflow.updatedAt', 'workflow.versionId', 'workflow.settings', + 'workflow.description', ]); return; } diff --git a/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts b/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts index cc99b738d40..6bc0ee1c082 100644 --- a/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts +++ b/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts @@ -38,20 +38,20 @@ describe('List query middleware', () => { }); test('should parse valid filter', async () => { - mockReq.query = { filter: '{ "name": "My Workflow" }' }; + mockReq.query = { filter: '{ "query": "My Workflow" }' }; await filterListQueryMiddleware(...args); - expect(mockReq.listQueryOptions).toEqual({ filter: { name: 'My Workflow' } }); + expect(mockReq.listQueryOptions).toEqual({ filter: { query: 'My Workflow' } }); expect(nextFn).toBeCalledTimes(1); }); test('should ignore invalid filter', async () => { - mockReq.query = { filter: '{ "name": "My Workflow", "foo": "bar" }' }; + mockReq.query = { filter: '{ "query": "My Workflow", "foo": "bar" }' }; await filterListQueryMiddleware(...args); - expect(mockReq.listQueryOptions).toEqual({ filter: { name: 'My Workflow' } }); + expect(mockReq.listQueryOptions).toEqual({ filter: { query: 'My Workflow' } }); expect(nextFn).toBeCalledTimes(1); }); @@ -64,7 +64,7 @@ describe('List query middleware', () => { }); test('should throw on valid filter with invalid type', async () => { - mockReq.query = { filter: '{ "name" : 123 }' }; + mockReq.query = { filter: '{ "query" : 123 }' }; await filterListQueryMiddleware(...args); @@ -257,27 +257,27 @@ describe('List query middleware', () => { describe('Combinations', () => { test('should combine filter with select', async () => { - mockReq.query = { filter: '{ "name": "My Workflow" }', select: '["name", "id"]' }; + mockReq.query = { filter: '{ "query": "My Workflow" }', select: '["name", "id"]' }; await filterListQueryMiddleware(...args); await selectListQueryMiddleware(...args); expect(mockReq.listQueryOptions).toEqual({ select: { name: true, id: true }, - filter: { name: 'My Workflow' }, + filter: { query: 'My Workflow' }, }); expect(nextFn).toBeCalledTimes(2); }); test('should combine filter with pagination options', async () => { - mockReq.query = { filter: '{ "name": "My Workflow" }', skip: '1', take: '2' }; + mockReq.query = { filter: '{ "query": "My Workflow" }', skip: '1', take: '2' }; await filterListQueryMiddleware(...args); await paginationListQueryMiddleware(...args); expect(mockReq.listQueryOptions).toEqual({ - filter: { name: 'My Workflow' }, + filter: { query: 'My Workflow' }, skip: 1, take: 2, }); diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts index 46f80be7604..9366c56d574 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts @@ -7,7 +7,7 @@ export class WorkflowFilter extends BaseFilter { @IsString() @IsOptional() @Expose() - name?: string; + query?: string; @IsBoolean() @IsOptional() diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts index 237a59f171e..e827e5903cb 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts @@ -14,6 +14,7 @@ export class WorkflowSelect extends BaseSelect { 'parentFolder', 'nodes', 'isArchived', + 'description', ]); } diff --git a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts index 8632b6c0de1..8a2cfb29f11 100644 --- a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts @@ -97,18 +97,16 @@ describe('search-workflows MCP tool', () => { }); await searchWorkflows(user, workflowService as unknown as WorkflowService, { limit: 500, - active: true, - name: 'foo', + query: 'foo', projectId: 'proj-1', }); const [_userArg, optionsArg] = (workflowService.getMany as jest.Mock).mock.calls[0]; - expect(optionsArg.take).toBe(200); // clamped to MAX_RESULTS + expect(optionsArg.take).toBe(200); expect(optionsArg.filter).toMatchObject({ isArchived: false, availableInMCP: true, - active: true, - name: 'foo', + query: 'foo', projectId: 'proj-1', }); }); diff --git a/packages/cli/src/modules/mcp/mcp.types.ts b/packages/cli/src/modules/mcp/mcp.types.ts index 281ef4f7535..c16f6279002 100644 --- a/packages/cli/src/modules/mcp/mcp.types.ts +++ b/packages/cli/src/modules/mcp/mcp.types.ts @@ -16,8 +16,7 @@ export type ToolDefinition = { // Shared MCP tool types export type SearchWorkflowsParams = { limit?: number; - active?: boolean; - name?: string; + query?: string; projectId?: string; }; diff --git a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts index de9bca88abc..d64cc168e9d 100644 --- a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts +++ b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts @@ -26,8 +26,7 @@ const inputSchema = { .max(MAX_RESULTS) .optional() .describe(`Limit the number of results (max ${MAX_RESULTS})`), - active: z.boolean().optional().describe('Filter by active status'), - name: z.string().optional().describe('Filter by name'), + query: z.string().optional().describe('Filter by name or description'), projectId: z.string().optional(), } satisfies z.ZodRawShape; @@ -37,6 +36,7 @@ const outputSchema = { z.object({ id: z.string(), name: z.string().nullable(), + description: z.string().nullable(), active: z.boolean().nullable(), createdAt: z.string().nullable(), updatedAt: z.string().nullable(), @@ -65,8 +65,8 @@ export const createSearchWorkflowsTool = ( inputSchema, outputSchema, }, - handler: async ({ limit = MAX_RESULTS, active, name, projectId }) => { - const parameters = { limit, active, name, projectId }; + handler: async ({ limit = MAX_RESULTS, query, projectId }) => { + const parameters = { limit, query, projectId }; const telemetryPayload: UserCalledMCPToolEventPayload = { user_id: user.id, tool_name: 'search_workflows', @@ -76,8 +76,7 @@ export const createSearchWorkflowsTool = ( try { const payload: SearchWorkflowsResult = await searchWorkflows(user, workflowService, { limit, - active, - name, + query, projectId, }); @@ -116,7 +115,7 @@ export const createSearchWorkflowsTool = ( export async function searchWorkflows( user: User, workflowService: WorkflowService, - { limit = MAX_RESULTS, active, name, projectId }: SearchWorkflowsParams, + { limit = MAX_RESULTS, query, projectId }: SearchWorkflowsParams, ): Promise { const safeLimit = Math.min(Math.max(1, limit), MAX_RESULTS); @@ -125,13 +124,14 @@ export async function searchWorkflows( filter: { isArchived: false, availableInMCP: true, - ...(active !== undefined ? { active } : {}), - ...(name ? { name } : {}), + active: true, + ...(query ? { query } : {}), ...(projectId ? { projectId } : {}), }, select: { id: true, name: true, + description: true, active: true, createdAt: true, updatedAt: true, @@ -149,9 +149,10 @@ export async function searchWorkflows( ); const formattedWorkflows: SearchWorkflowsItem[] = (workflows as WorkflowEntity[]).map( - ({ id, name, active, createdAt, updatedAt, triggerCount, nodes }) => ({ + ({ id, name, description, active, createdAt, updatedAt, triggerCount, nodes }) => ({ id, name, + description, active, createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 57598068cb5..d5fb12a7949 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -16,6 +16,7 @@ export declare namespace WorkflowRequest { type CreateUpdatePayload = Partial<{ id: string; // deleted if sent name: string; + description: string | null; nodes: INode[]; connections: IConnections; settings: IWorkflowSettings; diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 15215a4bbfe..355d0403f79 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -297,6 +297,7 @@ export class WorkflowService { 'staticData', 'pinData', 'versionId', + 'description', ]); if (parentFolderId) { diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 514a943ee5d..2b5c22aa945 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -829,18 +829,19 @@ describe('GET /workflows', () => { }); describe('filter', () => { - test('should filter workflows by field: name', async () => { - await createWorkflow({ name: 'First' }, owner); - await createWorkflow({ name: 'Second' }, owner); + test('should filter workflows by field: query', async () => { + await createWorkflow({ name: 'First', description: 'A workflow' }, owner); + await createWorkflow({ name: 'Second', description: 'Also a workflow' }, owner); + await createWorkflow({ name: 'Third', description: 'My first workflow' }, owner); const response = await authOwnerAgent .get('/workflows') - .query('filter={"name":"First"}') + .query('filter={"query":"first"}') .expect(200); expect(response.body).toEqual({ - count: 1, - data: [objectContaining({ name: 'First' })], + count: 2, + data: [objectContaining({ name: 'First' }), objectContaining({ name: 'Third' })], }); }); @@ -1461,7 +1462,7 @@ describe('GET /workflows', () => { const response = await authOwnerAgent .get('/workflows') .query('take=2&skip=1') - .query('filter={"name":"Special"}') + .query('filter={"query":"Special"}') .expect(200); expect(response.body.data).toHaveLength(2); @@ -1797,7 +1798,7 @@ describe('GET /workflows?includeFolders=true', () => { }); describe('filter', () => { - test('should filter workflows and folders by field: name', async () => { + test('should filter workflows and folders by field: query', async () => { const workflow1 = await createWorkflow({ name: 'First' }, owner); await createWorkflow({ name: 'Second' }, owner); @@ -1806,7 +1807,7 @@ describe('GET /workflows?includeFolders=true', () => { const folder1 = await createFolder(ownerProject, { name: 'First' }); const response = await authOwnerAgent .get('/workflows') - .query('filter={"name":"First"}&includeFolders=true') + .query('filter={"query":"First"}&includeFolders=true') .expect(200); expect(response.body).toEqual({ @@ -1919,7 +1920,7 @@ describe('GET /workflows?includeFolders=true', () => { expect(response2.body.data).toHaveLength(0); }); - test('should filter workflows by parentFolderId and its descendants when filtering by name', async () => { + test('should filter workflows by parentFolderId and its descendants when filtering by query', async () => { const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); await createFolder(pp, { @@ -1966,7 +1967,7 @@ describe('GET /workflows?includeFolders=true', () => { const filter2Response = await authOwnerAgent .get('/workflows') .query( - `filter={ "projectId": "${pp.id}", "parentFolderId": "${rootFolder2.id}", "name": "key" }&includeFolders=true`, + `filter={ "projectId": "${pp.id}", "parentFolderId": "${rootFolder2.id}", "query": "key" }&includeFolders=true`, ); expect(filter2Response.body.count).toBe(4); @@ -2258,7 +2259,7 @@ describe('GET /workflows?includeFolders=true', () => { const response = await authOwnerAgent .get('/workflows') .query('take=2&skip=1') - .query('filter={"name":"Special"}&includeFolders=true') + .query('filter={"query":"Special"}&includeFolders=true') .expect(200); expect(response.body.data).toHaveLength(2); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index b3b9928c740..de7623cd59f 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -154,6 +154,7 @@ import IconLucideMilestone from '~icons/lucide/milestone'; import IconLucideMinimize2 from '~icons/lucide/minimize-2'; import IconLucideMousePointer from '~icons/lucide/mouse-pointer'; import IconLucideNetwork from '~icons/lucide/network'; +import IconLucideNotebookPen from '~icons/lucide/notebook-pen'; import IconLucidePackageOpen from '~icons/lucide/package-open'; import IconLucidePalette from '~icons/lucide/palette'; import IconLucidePanelLeft from '~icons/lucide/panel-left'; @@ -589,6 +590,7 @@ export const updatedIconSet = { milestone: IconLucideMilestone, 'mouse-pointer': IconLucideMousePointer, network: IconLucideNetwork, + 'notebook-pen': IconLucideNotebookPen, 'package-open': IconLucidePackageOpen, palette: IconLucidePalette, 'panel-left': IconLucidePanelLeft, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 6d0bd8f6684..8c5df5a4a5b 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -58,7 +58,7 @@ "generic.field": "field", "generic.fields": "fields", "generic.folderCount": "Folder | {count} Folder | {count} Folders", - "generic.folder": "folder", + "generic.folder": "Folder", "generic.keepBuilding": "Keep building", "generic.learnMore": "Learn more", "generic.reset": "Reset", @@ -97,6 +97,7 @@ "generic.dismiss": "Dismiss", "generic.saving": "Saving", "generic.name": "Name", + "generic.description": "Description", "generic.unsavedWork.confirmMessage.headline": "Save changes before leaving?", "generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.", "generic.unsavedWork.confirmMessage.confirmButtonText": "Save", @@ -1069,6 +1070,11 @@ "folder.delete.modal.confirmation": "What should we do with {folders} {workflows} in this folder?", "folder.count": "the {count} folder | the {count} folders", "workflow.count": "the {count} workflow | the {count} workflows", + "workflow.description.tooltip": "Edit workflow description", + "workflow.description.placeholder": "Describe the purpose and functionality of this workflow", + "workflow.description.placeholder.mcp": "This will help MCP clients understand when to use this workflow", + "workflow.description.placeholder.mcp.webhook": "Include details about the expected webhook inputs (payload format and whether it should be sent as request body or URL parameters)", + "workflow.description.error.title": "Problem updating workflow description", "folder.and.workflow.separator": "and", "folders.delete.action": "Archive all workflows and delete subfolders", "folders.delete.error.message": "Problem while deleting folder", @@ -1193,6 +1199,7 @@ "mainSidebar.whatsNew": "What’s New", "mainSidebar.whatsNew.fullChangelog": "Full changelog", "mcp.workflowNotEligable.description": "Only active, webhook-triggered workflows can be accessible through MCP", + "mcp.instanceLevelAccessDisabled.description": "Instance-level MCP access is disabled. Enable it to allow workflow-level access.", "mcp.workflowDeactivated.title": "MCP Access Disabled", "mcp.productionCheklist.title": "Enable MCP access", "mcp.productionCheklist.description": "Allow MCP clients to access this workflow", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts index cb6bae78272..44b1d6985dc 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts @@ -26,6 +26,7 @@ export interface WorkflowData { export interface WorkflowDataUpdate { id?: string; name?: string; + description?: string | null; nodes?: INode[]; connections?: IConnections; settings?: IWorkflowSettings; diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index e4983f4c976..5fff9f69f6e 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -235,6 +235,7 @@ export interface INewWorkflowData { export interface IWorkflowDb { id: string; name: string; + description?: string | null; active: boolean; isArchived: boolean; createdAt: number | string; @@ -271,6 +272,7 @@ export type FolderResource = BaseFolderItem & { export type WorkflowResource = BaseResource & { resourceType: 'workflow'; + description?: string; updatedAt: string; createdAt: string; active: boolean; @@ -334,6 +336,7 @@ export type WorkflowListItem = Omit< 'nodes' | 'connections' | 'pinData' | 'usedCredentials' | 'meta' > & { resource: 'workflow'; + description?: string; }; export type WorkflowListResource = WorkflowListItem | FolderListItem; diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue index 869317c1a52..2363a2743cb 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue @@ -282,6 +282,7 @@ async function onWorkflowDeactivated() { :read-only="readOnly" :current-folder="parentFolderForBreadcrumbs" :is-archived="workflow.isArchived" + :description="workflow.description" @workflow:deactivated="onWorkflowDeactivated" />
diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts new file mode 100644 index 00000000000..c18276fa8e7 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts @@ -0,0 +1,731 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { type MockedStore, mockedStore } from '@/__tests__/utils'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { nextTick } from 'vue'; +import WorkflowDescriptionPopover from '@/app/components/MainHeader/WorkflowDescriptionPopover.vue'; +import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import { useUIStore } from '@/app/stores/ui.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; +import { useToast } from '@/app/composables/useToast'; +import { useTelemetry } from '@/app/composables/useTelemetry'; +import { WEBHOOK_NODE_TYPE } from 'n8n-workflow'; +import { STORES } from '@n8n/stores'; + +vi.mock('@/app/composables/useToast', () => { + const showError = vi.fn(); + return { + useToast: () => ({ + showError, + }), + }; +}); + +vi.mock('@/app/composables/useTelemetry', () => { + const track = vi.fn(); + return { + useTelemetry: () => ({ + track, + }), + }; +}); + +const initialState = { + [STORES.SETTINGS]: { + settings: { + modules: { + mcp: { + enabled: false, + }, + }, + }, + }, +}; + +const renderComponent = createComponentRenderer(WorkflowDescriptionPopover, { + pinia: createTestingPinia({ initialState }), +}); + +describe('WorkflowDescriptionPopover', () => { + let workflowsStore: MockedStore; + let uiStore: MockedStore; + let settingsStore: MockedStore; + let telemetry: ReturnType; + let toast: ReturnType; + + beforeEach(() => { + workflowsStore = mockedStore(useWorkflowsStore); + uiStore = mockedStore(useUIStore); + settingsStore = mockedStore(useSettingsStore); + telemetry = useTelemetry(); + toast = useToast(); + + // Reset mocks + workflowsStore.saveWorkflowDescription = vi.fn().mockResolvedValue(undefined); + workflowsStore.workflow = { + id: 'test-workflow-id', + name: 'Test Workflow', + active: false, + isArchived: false, + createdAt: Date.now(), + updatedAt: Date.now(), + versionId: '1', + nodes: [], + connections: {}, + }; + uiStore.stateIsDirty = false; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Component rendering', () => { + it('should render the description button and default description', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + const button = getByTestId('workflow-description-button'); + await userEvent.click(button); + + const textarea = getByTestId('workflow-description-input'); + expect(textarea).toHaveValue('Initial description'); + }); + + it('should render empty string if there is no description', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + }, + }); + + const button = getByTestId('workflow-description-button'); + await userEvent.click(button); + + const textarea = getByTestId('workflow-description-input'); + expect(textarea).toHaveValue(''); + }); + }); + + describe('Popover interaction', () => { + it('should open popover when button is clicked', async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Test description', + }, + }); + + const button = getByTestId('workflow-description-button'); + expect(queryByTestId('workflow-description-edit-content')).not.toBeInTheDocument(); + + await userEvent.click(button); + expect(getByTestId('workflow-description-edit-content')).toBeInTheDocument(); + }); + + it('should focus textarea when popover opens', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + }, + }); + + const button = getByTestId('workflow-description-button'); + await userEvent.click(button); + await nextTick(); + + const textarea = getByTestId('workflow-description-input'); + expect(textarea).toHaveFocus(); + }); + + it('should save description when popover closes', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + const button = getByTestId('workflow-description-button'); + await userEvent.click(button); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'Updated description'); + + // Click outside to close popover + await userEvent.click(document.body); + + expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith( + 'test-workflow-id', + 'Updated description', + ); + }); + }); + + describe('Save and Cancel functionality', () => { + it('should save description when save button is clicked', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'New description'); + + const saveButton = getByTestId('workflow-description-save-button'); + await userEvent.click(saveButton); + + expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith( + 'test-workflow-id', + 'New description', + ); + expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', { + workflow_id: 'test-workflow-id', + description: 'New description', + }); + }); + + it('should save empty string when description is cleared', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + + const saveButton = getByTestId('workflow-description-save-button'); + await userEvent.click(saveButton); + + expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith('test-workflow-id', ''); + expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', { + workflow_id: 'test-workflow-id', + description: '', + }); + }); + + it('should disable save button when description has not changed', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const saveButton = getByTestId('workflow-description-save-button'); + expect(saveButton).toBeDisabled(); + }); + + it('should disable save button when whitespace-only changes result in same trimmed value', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: '', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + // Type only whitespace + await userEvent.type(textarea, ' '); + + const saveButton = getByTestId('workflow-description-save-button'); + // Should be disabled since trimmed value is still empty + expect(saveButton).toBeDisabled(); + }); + + it('should not save on Enter key when only whitespace is entered', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: '', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + // Type only whitespace + await userEvent.type(textarea, ' '); + await userEvent.keyboard('{Enter}'); + + // Should not save since canSave is false + expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled(); + }); + + it('should enable save button when description changes', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + const saveButton = getByTestId('workflow-description-save-button'); + expect(saveButton).not.toBeDisabled(); + }); + + it('should revert changes when cancel button is clicked', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'Changed description'); + + const cancelButton = getByTestId('workflow-description-cancel-button'); + await userEvent.click(cancelButton); + + // Re-open popover to check value + await userEvent.click(getByTestId('workflow-description-button')); + const textareaAfterCancel = getByTestId('workflow-description-input'); + expect(textareaAfterCancel).toHaveValue('Initial description'); + }); + + it('should disable cancel button during save', async () => { + workflowsStore.saveWorkflowDescription = vi.fn( + async () => await new Promise((resolve) => setTimeout(resolve, 100)), + ); + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + const saveButton = getByTestId('workflow-description-save-button'); + const cancelButton = getByTestId('workflow-description-cancel-button'); + + await userEvent.click(saveButton); + + // During save, cancel should be disabled + expect(cancelButton).toBeDisabled(); + }); + }); + + describe('Keyboard shortcuts', () => { + it('should save when Enter key is pressed', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'New description'); + await userEvent.keyboard('{Enter}'); + + expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith( + 'test-workflow-id', + 'New description', + ); + }); + + it('should allow new lines with Shift+Enter', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: '', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, 'Line 1'); + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + await userEvent.type(textarea, 'Line 2'); + + expect(textarea).toHaveValue('Line 1\nLine 2'); + expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled(); + }); + + it('should cancel when Escape key is pressed', async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'Changed description'); + await userEvent.keyboard('{Escape}'); + + // Check that popover is closed + expect(queryByTestId('workflow-description-edit-content')).not.toBeInTheDocument(); + + // Re-open to verify changes were reverted + await userEvent.click(getByTestId('workflow-description-button')); + const textareaAfterEscape = getByTestId('workflow-description-input'); + expect(textareaAfterEscape).toHaveValue('Initial description'); + }); + }); + + describe('Error handling', () => { + it('should show error toast when save fails', async () => { + const error = new Error('Save failed'); + workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error); + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + const saveButton = getByTestId('workflow-description-save-button'); + await userEvent.click(saveButton); + + await vi.waitFor(() => { + expect(toast.showError).toHaveBeenCalledWith( + error, + 'Problem updating workflow description', + ); + }); + }); + + it('should revert to last saved value on error', async () => { + const error = new Error('Save failed'); + workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error); + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'Failed update'); + + const saveButton = getByTestId('workflow-description-save-button'); + await userEvent.click(saveButton); + + await vi.waitFor(() => { + expect(textarea).toHaveValue('Initial description'); + }); + }); + }); + + describe('Dirty state management', () => { + it('should set dirty flag when description changes', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + expect(uiStore.stateIsDirty).toBe(false); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + expect(uiStore.stateIsDirty).toBe(true); + }); + + it('should clear dirty flag when saving', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + expect(uiStore.stateIsDirty).toBe(true); + + const saveButton = getByTestId('workflow-description-save-button'); + await userEvent.click(saveButton); + + await vi.waitFor(() => { + expect(uiStore.stateIsDirty).toBe(false); + }); + }); + + it('should clear dirty flag when canceling', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + expect(uiStore.stateIsDirty).toBe(true); + + const cancelButton = getByTestId('workflow-description-cancel-button'); + await userEvent.click(cancelButton); + + expect(uiStore.stateIsDirty).toBe(false); + }); + + it('should handle whitespace-only changes correctly', async () => { + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: ' Initial ', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'Initial'); + + // Should not be dirty since trimmed values are the same + expect(uiStore.stateIsDirty).toBe(false); + }); + }); + + describe('MCP and webhook tooltips', () => { + it('should show base tooltip when MCP is disabled', async () => { + // Ensure MCP is disabled + settingsStore.isModuleActive = vi.fn().mockReturnValue(false); + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: '', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + // The tooltip text appears as placeholder in the textarea + const textarea = getByTestId('workflow-description-input'); + const placeholder = textarea.getAttribute('placeholder'); + expect(placeholder).toContain('Edit workflow description'); + // When MCP is disabled, should not contain MCP-specific text + expect(placeholder).not.toContain('MCP clients'); + }); + + it('should show MCP tooltip when MCP is enabled', async () => { + // Enable MCP module + settingsStore.isModuleActive = vi.fn().mockReturnValue(true); + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: '', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + const placeholder = textarea.getAttribute('placeholder'); + + // When MCP is enabled, the placeholder includes both base tooltip and MCP-specific text + expect(placeholder).toContain('Edit workflow description'); + expect(placeholder).toContain('MCP clients'); + // Should not include webhook notice when no webhooks + expect(placeholder).not.toContain('webhook'); + }); + + it('should show webhook notice when workflow has webhooks and MCP is enabled', async () => { + // Enable MCP module + settingsStore.isModuleActive = vi.fn().mockReturnValue(true); + + // Set up workflow with an enabled webhook node + workflowsStore.workflow = { + id: 'test-workflow-id', + name: 'Test Workflow', + active: false, + isArchived: false, + createdAt: Date.now(), + updatedAt: Date.now(), + versionId: '1', + nodes: [ + { + id: 'webhook-1', + name: 'Webhook', + type: WEBHOOK_NODE_TYPE, + disabled: false, + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + }; + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: '', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + const placeholder = textarea.getAttribute('placeholder'); + + // When MCP is enabled and webhook is present, the placeholder includes all three parts + expect(placeholder).toContain('Edit workflow description'); + expect(placeholder).toContain('MCP clients'); + expect(placeholder).toContain('webhook inputs'); + expect(placeholder).toContain('payload format'); + }); + + it('should not show webhook notice for disabled webhook nodes', async () => { + // Enable MCP module + settingsStore.isModuleActive = vi.fn().mockReturnValue(true); + + // Set up workflow with a disabled webhook node + workflowsStore.workflow = { + id: 'test-workflow-id', + name: 'Test Workflow', + active: false, + isArchived: false, + createdAt: Date.now(), + updatedAt: Date.now(), + versionId: '1', + nodes: [ + { + id: 'webhook-1', + name: 'Webhook', + type: WEBHOOK_NODE_TYPE, + disabled: true, + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + }; + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: '', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + const placeholder = textarea.getAttribute('placeholder'); + + // Should show MCP text but not webhook notice for disabled webhooks + expect(placeholder).toContain('Edit workflow description'); + expect(placeholder).toContain('MCP clients'); + expect(placeholder).not.toContain('webhook inputs'); + expect(placeholder).not.toContain('payload format'); + }); + }); + + describe('UI state tracking', () => { + it('should track active actions during save', async () => { + const addActiveActionSpy = vi.spyOn(uiStore, 'addActiveAction'); + const removeActiveActionSpy = vi.spyOn(uiStore, 'removeActiveAction'); + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + const saveButton = getByTestId('workflow-description-save-button'); + await userEvent.click(saveButton); + + expect(addActiveActionSpy).toHaveBeenCalledWith('workflowSaving'); + + await vi.waitFor(() => { + expect(removeActiveActionSpy).toHaveBeenCalledWith('workflowSaving'); + }); + }); + + it('should remove active action even on error', async () => { + const removeActiveActionSpy = vi.spyOn(uiStore, 'removeActiveAction'); + workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(new Error('Failed')); + + const { getByTestId } = renderComponent({ + props: { + workflowId: 'test-workflow-id', + workflowDescription: 'Initial description', + }, + }); + + await userEvent.click(getByTestId('workflow-description-button')); + + const textarea = getByTestId('workflow-description-input'); + await userEvent.type(textarea, ' updated'); + + const saveButton = getByTestId('workflow-description-save-button'); + await userEvent.click(saveButton); + + await vi.waitFor(() => { + expect(removeActiveActionSpy).toHaveBeenCalledWith('workflowSaving'); + }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue new file mode 100644 index 00000000000..73c54cd267d --- /dev/null +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue @@ -0,0 +1,252 @@ + + + + diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue index 23a65250c84..6a4af9e89aa 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue @@ -74,6 +74,8 @@ import { N8nInlineTextEdit, N8nTooltip, } from '@n8n/design-system'; +import WorkflowDescriptionPopover from './WorkflowDescriptionPopover.vue'; + const WORKFLOW_NAME_BP_TO_WIDTH: { [key: string]: number } = { XS: 150, SM: 200, @@ -92,6 +94,7 @@ const props = defineProps<{ active: IWorkflowDb['active']; currentFolder?: FolderShortInfo; isArchived: IWorkflowDb['isArchived']; + description?: IWorkflowDb['description']; }>(); const emit = defineEmits<{ @@ -843,7 +846,7 @@ const onWorkflowActiveToggle = async (value: { id: string; active: boolean }) => /> - + > {{ locale.baseText('workflows.item.archived') }} + @@ -1004,14 +1012,6 @@ $--header-spacing: 20px; max-width: 460px; } -.archived { - display: flex; - align-items: center; - width: 100%; - flex: 1; - margin-right: $--header-spacing; -} - .actions { display: flex; align-items: center; @@ -1087,4 +1087,13 @@ $--header-spacing: 20px; top: var(--spacing--xs); cursor: pointer; } + +.header-controls { + display: flex; + align-items: center; + gap: var(--spacing--md); + width: 100%; + flex: 1; + margin: 0 var(--spacing--md); +} diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue index 1ed73bb670f..85b2a29b037 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue @@ -509,20 +509,27 @@ const tags = computed( @click="onClick" >
{ expect(searchWorkflowsSpy).toHaveBeenCalledTimes(1); expect(searchWorkflowsSpy).toHaveBeenCalledWith( expect.objectContaining({ - name: undefined, + query: undefined, }), ); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index e05f1d09043..3ea8ec8e4a2 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -85,6 +85,9 @@ const defaultValues = ref({ }); const isMCPEnabled = computed(() => settingsStore.isModuleActive('mcp')); +const isInstanceMcpAccessEnabled = computed( + () => settingsStore.moduleSettings.mcp?.mcpAccessEnabled ?? false, +); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); const workflowName = computed(() => workflowsStore.workflowName); const workflowId = computed(() => workflowsStore.workflowId); @@ -99,6 +102,24 @@ const workflowOwnerName = computed(() => { }); const workflowPermissions = computed(() => getResourcePermissions(workflow.value?.scopes).workflow); +const mcpToggleDisabled = computed(() => { + return ( + readOnlyEnv.value || + !workflowPermissions.value.update || + !isEligibleForMcp.value || + !isInstanceMcpAccessEnabled.value + ); +}); + +const mcpToggleTooltip = computed(() => { + if (!isInstanceMcpAccessEnabled.value) { + return i18n.baseText('mcp.instanceLevelAccessDisabled.description'); + } else if (!isEligibleForMcp.value) { + return i18n.baseText('mcp.workflowNotEligable.description'); + } + return i18n.baseText('workflowSettings.availableInMCP.tooltip'); +}); + const isEligibleForMcp = computed(() => { if (!workflow?.value) return false; return isEligibleForMcpAccess(workflow.value); @@ -279,7 +300,7 @@ const loadTimezones = async () => { const loadWorkflows = async (searchTerm?: string) => { const workflowsData = (await workflowsStore.searchWorkflows({ - name: searchTerm, + query: searchTerm, })) as IWorkflowShortResponse[]; workflowsData.sort((a, b) => { if (a.name.toLowerCase() < b.name.toLowerCase()) { @@ -857,11 +878,7 @@ onBeforeUnmount(() => { {{ i18n.baseText('workflowSettings.availableInMCP') }} @@ -869,13 +886,13 @@ onBeforeUnmount(() => {
- + & { settings: NonNullable } = { name: '', + description: '', active: false, isArchived: false, createdAt: -1, @@ -609,7 +610,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { pageSize = DEFAULT_WORKFLOW_PAGE_SIZE, sortBy?: string, filters: { - name?: string; + query?: string; tags?: string[]; active?: boolean; isArchived?: boolean; @@ -652,20 +653,20 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { async function searchWorkflows({ projectId, - name, + query, nodeTypes, tags, select, }: { projectId?: string; - name?: string; + query?: string; nodeTypes?: string[]; tags?: string[]; select?: string[]; }): Promise { const filter = { projectId, - name, + query, nodeTypes, tags, }; @@ -885,6 +886,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { workflow.value.isArchived = isArchived; } + function setDescription(description: string | undefined | null) { + workflow.value.description = description; + } + async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise { if ( currentWorkflowName && @@ -1611,6 +1616,47 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return updated; } + async function saveWorkflowDescription( + id: string, + description: string | null, + ): Promise { + let currentVersionId = ''; + const isCurrentWorkflow = id === workflow.value.id; + + if (isCurrentWorkflow) { + currentVersionId = workflow.value.versionId; + } else { + const cached = workflowsById.value[id]; + if (cached?.versionId) { + currentVersionId = cached.versionId; + } else { + const fetched = await fetchWorkflow(id); + currentVersionId = fetched.versionId; + } + } + + const updated = await updateWorkflow(id, { + versionId: currentVersionId, + description, + }); + + // Update local store state + if (isCurrentWorkflow) { + setDescription(updated.description ?? ''); + if (updated.versionId !== currentVersionId) { + setWorkflowVersionId(updated.versionId); + } + } else if (workflowsById.value[id]) { + workflowsById.value[id] = { + ...workflowsById.value[id], + description: updated.description, + versionId: updated.versionId, + }; + } + + return updated; + } + async function runWorkflow(startRunData: IStartRunData): Promise { if (startRunData.workflowData.settings === null) { startRunData.workflowData.settings = undefined; @@ -1859,6 +1905,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { setWorkflowInactive, fetchActiveWorkflows, setIsArchived, + setDescription, getDuplicateCurrentWorkflowName, setWorkflowExecutionRunData, setWorkflowPinData, @@ -1887,6 +1934,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { createNewWorkflow, updateWorkflow, updateWorkflowSetting, + saveWorkflowDescription, runWorkflow, removeTestWebhook, fetchExecutionDataById, diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts index 0e776e4f39b..6f68c4a0113 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts @@ -289,7 +289,7 @@ describe('WorkflowsView', () => { expect.any(Number), expect.any(String), expect.objectContaining({ - name: 'one', + query: 'one', isArchived: false, }), expect.any(Boolean), diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue index 038f41f0a5c..85fe8de6231 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue @@ -348,6 +348,7 @@ const workflowListResources = computed(() => { resourceType: 'workflow', id: resource.id, name: resource.name, + description: resource.description, active: resource.active ?? false, isArchived: resource.isArchived, updatedAt: resource.updatedAt.toString(), @@ -672,7 +673,7 @@ const fetchWorkflows = async () => { pageSize.value, currentSort.value, { - name: filters.value.search || undefined, + query: filters.value.search || undefined, active: activeFilter, isArchived: archivedFilter, tags: tags.length ? tags : undefined, diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue index 3635c57eb67..9598027e73f 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue @@ -62,6 +62,15 @@ const tableHeaders = ref>>([ return; }, }, + { + title: i18n.baseText('generic.description'), + key: 'description', + width: 300, + disableSort: true, + value() { + return; + }, + }, { title: '', key: 'actions', @@ -165,6 +174,19 @@ const onWorkflowAction = (action: string, workflow: WorkflowListItem) => { + diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue index f1f27d9986a..4b1d90c0f1c 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue @@ -72,8 +72,16 @@ const isProtectedEnvironment = computed(() => { return sourceControlStore.preferences.branchReadOnly; }); +// Show MCP action if: +// - MCP module is active +// - Instance-level access is enabled +// - Workflow is eligible for MCP access const isMcpAvailable = computed(() => { - return settingsStore.isModuleActive('mcp') && isEligibleForMcpAccess(props.workflow); + return ( + settingsStore.isModuleActive('mcp') && + settingsStore.moduleSettings.mcp?.mcpAccessEnabled && + isEligibleForMcpAccess(props.workflow) + ); }); const availableActions = computed(() => { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index 3ea8ec8e4a2..5816376535b 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -84,9 +84,8 @@ const defaultValues = ref({ availableInMCP: false, }); -const isMCPEnabled = computed(() => settingsStore.isModuleActive('mcp')); -const isInstanceMcpAccessEnabled = computed( - () => settingsStore.moduleSettings.mcp?.mcpAccessEnabled ?? false, +const isMCPEnabled = computed( + () => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled, ); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); const workflowName = computed(() => workflowsStore.workflowName); @@ -103,18 +102,11 @@ const workflowOwnerName = computed(() => { const workflowPermissions = computed(() => getResourcePermissions(workflow.value?.scopes).workflow); const mcpToggleDisabled = computed(() => { - return ( - readOnlyEnv.value || - !workflowPermissions.value.update || - !isEligibleForMcp.value || - !isInstanceMcpAccessEnabled.value - ); + return readOnlyEnv.value || !workflowPermissions.value.update || !isEligibleForMcp.value; }); const mcpToggleTooltip = computed(() => { - if (!isInstanceMcpAccessEnabled.value) { - return i18n.baseText('mcp.instanceLevelAccessDisabled.description'); - } else if (!isEligibleForMcp.value) { + if (!isEligibleForMcp.value) { return i18n.baseText('mcp.workflowNotEligable.description'); } return i18n.baseText('workflowSettings.availableInMCP.tooltip'); diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue index 8dc63445415..2e918d8b169 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue @@ -2,7 +2,7 @@ import { useConsentStore } from '@/app/stores/consent.store'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { useI18n } from '@n8n/i18n'; -import { onMounted, computed } from 'vue'; +import { onMounted, computed, ref } from 'vue'; import type { ConsentDetails } from '@n8n/rest-api-client/api/consent'; import { N8nButton, N8nHeading, N8nIcon, N8nLogo, N8nNotice, N8nText } from '@n8n/design-system'; import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants'; @@ -17,8 +17,10 @@ const i18n = useI18n(); const documentTitle = useDocumentTitle(); const toast = useToast(); -const error = computed(() => consentStore.error); +// Success state: +const waitingForRedirect = ref(false); +const error = computed(() => consentStore.error); const loading = computed(() => consentStore.isLoading); const clentDetails = computed(() => consentStore.consentDetails); @@ -37,6 +39,7 @@ const clientIcon = computed(() => { const handleAllow = async () => { try { const response = await consentStore.approveConsent(true); + waitingForRedirect.value = true; window.location.href = response.redirectUrl; } catch (err) { toast.showError(err, i18n.baseText('oauth.consentView.error.allow')); @@ -76,7 +79,17 @@ onMounted(async () => {
-
+ +
+ + {{ i18n.baseText('oauth.consentView.success.title') }} + + + {{ i18n.baseText('oauth.consentView.success.description') }} + +
+ +
{{ i18n.baseText('oauth.consentView.heading', { @@ -109,7 +122,7 @@ onMounted(async () => {

-