diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-csv.controller.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-csv.controller.integration.test.ts new file mode 100644 index 0000000000000..978c740033924 --- /dev/null +++ b/packages/cli/src/modules/data-table/__tests__/data-table-csv.controller.integration.test.ts @@ -0,0 +1,391 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { + createTeamProject, + getPersonalProject, + linkUserToProject, + testDb, +} from '@n8n/backend-test-utils'; +import type { Project, User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { createDataTable } from '@test-integration/db/data-tables'; +import { createOwner, createMember } from '@test-integration/db/users'; +import type { SuperAgentTest } from '@test-integration/types'; +import * as utils from '@test-integration/utils'; + +import { DataTableColumnRepository } from '../data-table-column.repository'; +import { DataTableRowsRepository } from '../data-table-rows.repository'; +import { mockDataTableSizeValidator } from './test-helpers'; + +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; +let ownerProject: Project; +let memberProject: Project; + +const testServer = utils.setupTestServer({ + endpointGroups: ['data-table'], + modules: ['data-table'], +}); +let dataTableColumnRepository: DataTableColumnRepository; +let dataTableRowsRepository: DataTableRowsRepository; + +beforeAll(async () => { + mockDataTableSizeValidator(); + + dataTableColumnRepository = Container.get(DataTableColumnRepository); + dataTableRowsRepository = Container.get(DataTableRowsRepository); + + owner = await createOwner(); + member = await createMember(); + + authOwnerAgent = testServer.authAgentFor(owner); + authMemberAgent = testServer.authAgentFor(member); + + ownerProject = await getPersonalProject(owner); + memberProject = await getPersonalProject(member); +}); + +beforeEach(async () => { + await testDb.truncate(['DataTable', 'DataTableColumn']); +}); + +describe('GET /projects/:projectId/data-tables/:dataTableId/download-csv', () => { + test('should not download CSV when project does not exist', async () => { + await authOwnerAgent + .get('/projects/non-existing-id/data-tables/some-data-table-id/download-csv') + .expect(404); + }); + + test('should not download CSV when data table does not exist', async () => { + const project = await createTeamProject('test project', owner); + + await authOwnerAgent + .get(`/projects/${project.id}/data-tables/non-existing-id/download-csv`) + .expect(404); + }); + + test('should not download CSV if user has no access to project', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Test Data Table', + columns: [{ name: 'test_column', type: 'string' }], + }); + + await authMemberAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(403); + }); + + test("should not download CSV from another user's personal project", async () => { + const dataTable = await createDataTable(ownerProject, { + name: 'Personal Data Table', + columns: [{ name: 'test_column', type: 'string' }], + }); + + await authMemberAgent + .get(`/projects/${ownerProject.id}/data-tables/${dataTable.id}/download-csv`) + .expect(403); + }); + + test('should download CSV with headers only for empty table', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Empty Table', + columns: [ + { name: 'firstName', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + expect(response.body.data.dataTableName).toBe('Empty Table'); + expect(response.body.data.csvContent).toBe('id,firstName,age,createdAt,updatedAt'); + }); + + test('should download CSV with data rows', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'People', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + // Get columns for insertRows + const columns = await dataTableColumnRepository.getColumns(dataTable.id); + + // Insert rows + await dataTableRowsRepository.insertRows( + dataTable.id, + [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + columns, + 'id', + ); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + expect(response.body.data.dataTableName).toBe('People'); + + const csvContent = response.body.data.csvContent; + const lines = csvContent.split('\n'); + + // Check header + expect(lines[0]).toBe('id,name,age,createdAt,updatedAt'); + + // Check data rows exist + expect(lines.length).toBe(3); // header + 2 rows + expect(csvContent).toContain('Alice'); + expect(csvContent).toContain('Bob'); + expect(csvContent).toContain('30'); + expect(csvContent).toContain('25'); + }); + + test('should properly escape special characters in CSV', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Special Chars', + columns: [{ name: 'description', type: 'string' }], + }); + + const columns = await dataTableColumnRepository.getColumns(dataTable.id); + + // Insert rows with special characters + await dataTableRowsRepository.insertRows( + dataTable.id, + [ + { description: 'Contains "quotes"' }, + { description: 'Contains, commas' }, + { description: 'Contains\nnewlines' }, + { description: ' leading and trailing ' }, + ], + columns, + 'id', + ); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + const csvContent = response.body.data.csvContent; + + // Check proper escaping + expect(csvContent).toContain('"Contains ""quotes"""'); // Quotes doubled and wrapped + expect(csvContent).toContain('"Contains, commas"'); // Wrapped due to comma + expect(csvContent).toContain('"Contains\nnewlines"'); // Wrapped due to newline + expect(csvContent).toContain('" leading and trailing "'); // Wrapped due to spaces + }); + + test('should handle different data types in CSV', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Mixed Types', + columns: [ + { name: 'text', type: 'string' }, + { name: 'number', type: 'number' }, + { name: 'flag', type: 'boolean' }, + { name: 'timestamp', type: 'date' }, + ], + }); + + const columns = await dataTableColumnRepository.getColumns(dataTable.id); + const testDate = new Date('2025-01-15T10:30:00.000Z'); + await dataTableRowsRepository.insertRows( + dataTable.id, + [ + { + text: 'hello', + number: 42, + flag: true, + timestamp: testDate, + }, + ], + columns, + 'id', + ); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + const csvContent = response.body.data.csvContent; + const lines = csvContent.split('\n'); + + expect(lines[0]).toBe('id,text,number,flag,timestamp,createdAt,updatedAt'); + expect(lines[1]).toContain('hello'); + expect(lines[1]).toContain('42'); + // Boolean values vary by database: SQLite/MySQL use 0/1, PostgreSQL uses true/false + expect(lines[1]).toMatch(/,(true|1),/); + // Check for date in ISO format (timezone may vary) + expect(lines[1]).toMatch(/2025-01-15T\d{2}:\d{2}:\d{2}\.\d{3}Z/); + }); + + test('should handle null values in CSV', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Nullable Fields', + columns: [ + { name: 'required', type: 'string' }, + { name: 'optional', type: 'string' }, + ], + }); + + const columns = await dataTableColumnRepository.getColumns(dataTable.id); + + await dataTableRowsRepository.insertRows( + dataTable.id, + [{ required: 'value', optional: null }], + columns, + 'id', + ); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + const csvContent = response.body.data.csvContent; + const lines = csvContent.split('\n'); + + // Null should be represented as empty field + expect(lines[1]).toMatch(/,value,,/); // Empty field for null value + }); + + test('should download CSV with correct column order', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Ordered Columns', + columns: [ + { name: 'third', type: 'string' }, + { name: 'first', type: 'string' }, + { name: 'second', type: 'string' }, + ], + }); + + // Reorder columns + const columns = await dataTableColumnRepository.getColumns(dataTable.id); + const thirdCol = columns.find((c) => c.name === 'third'); + const firstCol = columns.find((c) => c.name === 'first'); + const secondCol = columns.find((c) => c.name === 'second'); + + if (thirdCol && firstCol && secondCol) { + await dataTableColumnRepository.update(firstCol.id, { index: 0 }); + await dataTableColumnRepository.update(secondCol.id, { index: 1 }); + await dataTableColumnRepository.update(thirdCol.id, { index: 2 }); + } + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + const csvContent = response.body.data.csvContent; + const lines = csvContent.split('\n'); + + // Columns should be ordered by index + expect(lines[0]).toBe('id,first,second,third,createdAt,updatedAt'); + }); + + test('should allow CSV download if user has project:viewer role', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + const dataTable = await createDataTable(project, { + name: 'Viewable Table', + columns: [{ name: 'data', type: 'string' }], + }); + + const response = await authMemberAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + expect(response.body.data.dataTableName).toBe('Viewable Table'); + expect(response.body.data.csvContent).toContain('id,data,createdAt,updatedAt'); + }); + + test('should allow CSV download if user has project:editor role', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:editor'); + const dataTable = await createDataTable(project, { + name: 'Editable Table', + columns: [{ name: 'data', type: 'string' }], + }); + + const response = await authMemberAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + expect(response.body.data.dataTableName).toBe('Editable Table'); + expect(response.body.data.csvContent).toContain('id,data,createdAt,updatedAt'); + }); + + test('should download CSV from personal project data table', async () => { + const dataTable = await createDataTable(memberProject, { + name: 'Personal CSV Export', + columns: [{ name: 'info', type: 'string' }], + }); + + const columns = await dataTableColumnRepository.getColumns(dataTable.id); + + await dataTableRowsRepository.insertRows( + dataTable.id, + [{ info: 'personal data' }], + columns, + 'id', + ); + + const response = await authMemberAgent + .get(`/projects/${memberProject.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + expect(response.body.data.dataTableName).toBe('Personal CSV Export'); + expect(response.body.data.csvContent).toContain('personal data'); + }); + + test('should handle table name with special characters', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Table "With" Special, Chars', + columns: [{ name: 'data', type: 'string' }], + }); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + // Table name should be returned correctly + expect(response.body.data.dataTableName).toBe('Table "With" Special, Chars'); + }); + + test('should handle column names with underscores and numbers', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + name: 'Valid Column Names', + columns: [ + { name: 'first_name', type: 'string' }, + { name: 'age_2', type: 'string' }, + { name: 'email_address', type: 'string' }, + ], + }); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`) + .expect(200); + + const csvContent = response.body.data.csvContent; + const lines = csvContent.split('\n'); + + // Column names should be included in header + expect(lines[0]).toContain('first_name'); + expect(lines[0]).toContain('age_2'); + expect(lines[0]).toContain('email_address'); + }); +}); diff --git a/packages/cli/src/modules/data-table/data-table.controller.ts b/packages/cli/src/modules/data-table/data-table.controller.ts index 196b3df707a64..b315c7666f8fb 100644 --- a/packages/cli/src/modules/data-table/data-table.controller.ts +++ b/packages/cli/src/modules/data-table/data-table.controller.ts @@ -261,6 +261,36 @@ export class DataTableController { } } + @Get('/:dataTableId/download-csv') + @ProjectScope('dataTable:read') + async downloadDataTableCsv( + req: AuthenticatedRequest<{ projectId: string; dataTableId: string }>, + _res: Response, + ) { + try { + const { projectId, dataTableId } = req.params; + + // Generate CSV content - this will validate that the table exists + const { csvContent, dataTableName } = await this.dataTableService.generateDataTableCsv( + dataTableId, + projectId, + ); + + return { + csvContent, + dataTableName, + }; + } catch (e: unknown) { + if (e instanceof DataTableNotFoundError) { + throw new NotFoundError(e.message); + } else if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } else { + throw e; + } + } + } + /** * @returns the IDs of the inserted rows */ diff --git a/packages/cli/src/modules/data-table/data-table.service.ts b/packages/cli/src/modules/data-table/data-table.service.ts index 46dbdb7177710..d08712ff73c34 100644 --- a/packages/cli/src/modules/data-table/data-table.service.ts +++ b/packages/cli/src/modules/data-table/data-table.service.ts @@ -688,4 +688,109 @@ export class DataTableService { dataTables: accessibleDataTables, }; } + + async generateDataTableCsv( + dataTableId: string, + projectId: string, + ): Promise<{ csvContent: string; dataTableName: string }> { + const dataTable = await this.validateDataTableExists(dataTableId, projectId); + + const columns = await this.dataTableColumnRepository.getColumns(dataTableId); + + const { data: rows } = await this.dataTableRowsRepository.getManyAndCount( + dataTableId, + { + skip: 0, + }, + columns, + ); + + const csvContent = this.buildCsvContent(rows, columns); + + return { + csvContent, + dataTableName: dataTable.name, + }; + } + + private buildCsvContent(rows: DataTableRowReturn[], columns: DataTableColumn[]): string { + const sortedColumns = [...columns].sort((a, b) => a.index - b.index); + + const userHeaders = sortedColumns.map((col) => col.name); + const headers = ['id', ...userHeaders, 'createdAt', 'updatedAt']; + + const csvRows: string[] = [headers.map((h) => this.escapeCsvValue(h)).join(',')]; + + for (const row of rows) { + const values: string[] = []; + + values.push(this.escapeCsvValue(row.id)); + + for (const column of sortedColumns) { + const value = row[column.name]; + values.push(this.escapeCsvValue(this.formatValueForCsv(value, column.type))); + } + + values.push(this.escapeCsvValue(this.formatDateForCsv(row.createdAt))); + values.push(this.escapeCsvValue(this.formatDateForCsv(row.updatedAt))); + + csvRows.push(values.join(',')); + } + + return csvRows.join('\n'); + } + + private formatValueForCsv(value: unknown, columnType: DataTableColumnType): string { + if (value === null || value === undefined) { + return ''; + } + + if (columnType === 'date') { + if (value instanceof Date || typeof value === 'string') { + return this.formatDateForCsv(value); + } + } + + if (columnType === 'boolean') { + return String(value); + } + + if (columnType === 'number') { + return String(value); + } + + return String(value); + } + + private formatDateForCsv(date: Date | string): string { + if (date instanceof Date) { + return date.toISOString(); + } + // If it's already a string, try to parse and format + const parsed = new Date(date); + return !isNaN(parsed.getTime()) ? parsed.toISOString() : String(date); + } + + private escapeCsvValue(value: unknown): string { + const str = String(value); + + // RFC 4180 compliant escaping: + // - If value contains comma, quote, or newline, wrap in quotes + // - Also wrap if value has leading/trailing spaces to prevent trimming + // - Escape quotes by doubling them + const hasLeadingOrTrailingSpace = + str.length > 0 && (str[0] === ' ' || str[str.length - 1] === ' '); + + if ( + str.includes(',') || + str.includes('"') || + str.includes('\n') || + str.includes('\r') || + hasLeadingOrTrailingSpace + ) { + return `"${str.replace(/"/g, '""')}"`; + } + + return str; + } } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e5cfeb709a712..d1a7b56a0f6cc 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3300,6 +3300,8 @@ "dataTable.import.invalidColumnName": "Only alphabetical and non-leading numbers and underscores allowed", "dataTable.delete.confirm.message": "Are you sure you want to delete the data table '{name}'? This action cannot be undone.", "dataTable.delete.error": "Error deleting data table", + "dataTable.download.csv": "Download CSV", + "dataTable.download.error": "Failed to download data table", "dataTable.rename.error": "Error renaming data table", "dataTable.getDetails.error": "Error fetching data table details", "dataTable.notFound": "Data table not found", diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/components/DataTableActions.vue b/packages/frontend/editor-ui/src/features/core/dataTable/components/DataTableActions.vue index c4e71e13dcb8b..0b8a6957db7c6 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/components/DataTableActions.vue +++ b/packages/frontend/editor-ui/src/features/core/dataTable/components/DataTableActions.vue @@ -40,6 +40,11 @@ const telemetry = useTelemetry(); const actions = computed>>(() => { const availableActions = [ + { + label: i18n.baseText('dataTable.download.csv'), + value: DATA_TABLE_CARD_ACTIONS.DOWNLOAD_CSV, + disabled: false, + }, { label: i18n.baseText('generic.delete'), value: DATA_TABLE_CARD_ACTIONS.DELETE, @@ -67,6 +72,10 @@ const onAction = async (action: string) => { }); break; } + case DATA_TABLE_CARD_ACTIONS.DOWNLOAD_CSV: { + await downloadDataTableCsv(); + break; + } case DATA_TABLE_CARD_ACTIONS.DELETE: { const promptResponse = await message.confirm( i18n.baseText('dataTable.delete.confirm.message', { @@ -86,6 +95,19 @@ const onAction = async (action: string) => { } }; +const downloadDataTableCsv = async () => { + try { + await dataTableStore.downloadDataTableCsv(props.dataTable.id, props.dataTable.projectId); + + telemetry.track('User downloaded data table CSV', { + data_table_id: props.dataTable.id, + data_table_project_id: props.dataTable.projectId, + }); + } catch (error) { + toast.showError(error, i18n.baseText('dataTable.download.error')); + } +}; + const deleteDataTable = async () => { try { const deleted = await dataTableStore.deleteDataTable( diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/constants.ts b/packages/frontend/editor-ui/src/features/core/dataTable/constants.ts index c09a8319bf4fd..f0ce342939159 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/constants.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/constants.ts @@ -21,6 +21,7 @@ export const DATA_TABLE_CARD_ACTIONS = { RENAME: 'rename', DELETE: 'delete', CLEAR: 'clear', + DOWNLOAD_CSV: 'download-csv', }; export const ADD_DATA_TABLE_MODAL_KEY = 'addDataTableModal'; diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts index b7f5aeedeb29d..dca73ba0d57cd 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts @@ -223,6 +223,25 @@ export const fetchDataTableGlobalLimitInBytes = async (context: IRestApiContext) ); }; +export const downloadDataTableCsvApi = async ( + context: IRestApiContext, + dataTableId: string, + projectId: string, +): Promise<{ csvContent: string; filename: string }> => { + const response = await makeRestApiRequest<{ csvContent: string; dataTableName: string }>( + context, + 'GET', + `/projects/${projectId}/data-tables/${dataTableId}/download-csv`, + ); + + // Use just the data table name as filename + const filename = `${response.dataTableName}.csv`; + + return { + csvContent: response.csvContent, + filename, + }; +}; export const uploadCsvFileApi = async ( context: IRestApiContext, file: File, diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts index e2d3789a0af16..188c72e15004a 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts @@ -15,6 +15,7 @@ import { updateDataTableRowsApi, deleteDataTableRowsApi, fetchDataTableGlobalLimitInBytes, + downloadDataTableCsvApi, uploadCsvFileApi, } from '@/features/core/dataTable/dataTable.api'; import type { @@ -39,6 +40,8 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => { const dataTableSizeLimitState = ref('ok'); const dataTableTableSizes = ref>({}); + const UTF8_BOM = '\uFEFF'; + const projectPermissions = computed(() => getResourcePermissions( projectStore.currentProject?.scopes ?? projectStore.personalProject?.scopes, @@ -273,6 +276,45 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => { return result; }; + const createCsvBlob = (csvContent: string): Blob => { + // Add BOM for Excel compatibility with special characters + return new Blob([UTF8_BOM + csvContent], { + type: 'text/csv;charset=utf-8;', + }); + }; + + const triggerBrowserDownload = (blob: Blob, filename: string): void => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + + try { + link.click(); + } finally { + // Ensure cleanup happens even if click fails + if (document.body.contains(link)) { + document.body.removeChild(link); + } + URL.revokeObjectURL(url); + } + }; + + const downloadDataTableCsv = async (dataTableId: string, projectId: string) => { + const { csvContent, filename } = await downloadDataTableCsvApi( + rootStore.restApiContext, + dataTableId, + projectId, + ); + + const csvBlob = createCsvBlob(csvContent); + triggerBrowserDownload(csvBlob, filename); + }; + return { dataTables, totalCount, @@ -295,6 +337,7 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => { insertEmptyRow, updateRow, deleteRows, + downloadDataTableCsv, projectPermissions, }; });