diff --git a/packages/@n8n/api-types/src/dto/data-table/add-data-table-rows.dto.ts b/packages/@n8n/api-types/src/dto/data-table/add-data-table-rows.dto.ts index d8d2a984fdf..fd2046bd716 100644 --- a/packages/@n8n/api-types/src/dto/data-table/add-data-table-rows.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-table/add-data-table-rows.dto.ts @@ -9,5 +9,5 @@ import { export class AddDataTableRowsDto extends Z.class({ data: z.array(z.record(dataTableColumnNameSchema, dataTableColumnValueSchema)), - returnType: insertRowReturnType, + returnType: insertRowReturnType.optional().default('count'), }) {} diff --git a/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts b/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts index d9994c840d9..bb3d0b964b7 100644 --- a/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts @@ -4,7 +4,7 @@ import { Z } from 'zod-class'; import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema'; import { dataTableColumnNameSchema } from '../../schemas/data-table.schema'; -import { paginationSchema } from '../pagination/pagination.dto'; +import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto'; const filterValidator = z .string() @@ -52,11 +52,13 @@ const sortByValidator = z try { column = dataTableColumnNameSchema.parse(column); - } catch { + } catch (e) { + const errorMessage = + e instanceof z.ZodError ? e.errors[0]?.message : 'Invalid sort columnName'; ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Invalid sort columnName', - path: ['sort'], + message: errorMessage, + path: ['sortBy'], }); return z.NEVER; } @@ -81,3 +83,11 @@ export class ListDataTableContentQueryDto extends Z.class({ sortBy: sortByValidator.optional(), search: z.string().optional(), }) {} + +export class PublicApiListDataTableContentQueryDto extends Z.class({ + limit: publicApiPaginationSchema.limit, + offset: publicApiPaginationSchema.offset, + filter: filterValidator.optional(), + sortBy: sortByValidator.optional(), + search: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts b/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts index 6037ae8cd9b..9e586da3a4b 100644 --- a/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts @@ -2,7 +2,7 @@ import { jsonParse } from 'n8n-workflow'; import { z } from 'zod'; import { Z } from 'zod-class'; -import { paginationSchema } from '../pagination/pagination.dto'; +import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto'; const VALID_SORT_OPTIONS = [ 'name:asc', @@ -11,8 +11,6 @@ const VALID_SORT_OPTIONS = [ 'createdAt:desc', 'updatedAt:asc', 'updatedAt:desc', - 'sizeBytes:asc', - 'sizeBytes:desc', ] as const; export type ListDataTableQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number]; @@ -68,3 +66,9 @@ export class ListDataTableQueryDto extends Z.class({ filter: filterValidator, sortBy: sortByValidator, }) {} + +export class PublicApiListDataTableQueryDto extends Z.class({ + ...publicApiPaginationSchema, + filter: filterValidator, + sortBy: sortByValidator, +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 481ff140a6a..4be1cbea617 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -112,8 +112,14 @@ export { UpdateDataTableDto } from './data-table/update-data-table.dto'; export { UpdateDataTableRowDto } from './data-table/update-data-table-row.dto'; export { DeleteDataTableRowsDto } from './data-table/delete-data-table-rows.dto'; export { UpsertDataTableRowDto } from './data-table/upsert-data-table-row.dto'; -export { ListDataTableQueryDto } from './data-table/list-data-table-query.dto'; -export { ListDataTableContentQueryDto } from './data-table/list-data-table-content-query.dto'; +export { + ListDataTableQueryDto, + PublicApiListDataTableQueryDto, +} from './data-table/list-data-table-query.dto'; +export { + ListDataTableContentQueryDto, + PublicApiListDataTableContentQueryDto, +} from './data-table/list-data-table-content-query.dto'; export { CreateDataTableColumnDto } from './data-table/create-data-table-column.dto'; export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto'; export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto'; diff --git a/packages/@n8n/api-types/src/dto/pagination/pagination.dto.ts b/packages/@n8n/api-types/src/dto/pagination/pagination.dto.ts index ee3d39f6419..114d68ad561 100644 --- a/packages/@n8n/api-types/src/dto/pagination/pagination.dto.ts +++ b/packages/@n8n/api-types/src/dto/pagination/pagination.dto.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { Z } from 'zod-class'; -export const MAX_ITEMS_PER_PAGE = 50; +export const MAX_ITEMS_PER_PAGE = 250; const skipValidator = z .string() @@ -39,3 +39,32 @@ export const paginationSchema = { }; export class PaginationDto extends Z.class(paginationSchema) {} + +const offsetValidator = z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 0)) + .refine((val) => !isNaN(val) && Number.isInteger(val), { + message: 'Param `offset` must be a valid integer', + }) + .refine((val) => val >= 0, { + message: 'Param `offset` must be a non-negative integer', + }); + +const createLimitValidator = (maxItems: number) => + z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 100)) + .refine((val) => !isNaN(val) && Number.isInteger(val), { + message: 'Param `limit` must be a valid integer', + }) + .refine((val) => val >= 0, { + message: 'Param `limit` must be a non-negative integer', + }) + .transform((val) => Math.min(val, maxItems)); + +export const publicApiPaginationSchema = { + offset: offsetValidator, + limit: createLimitValidator(MAX_ITEMS_PER_PAGE), +}; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index db0099e7278..23d90405e4e 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -67,6 +67,8 @@ export const API_KEY_RESOURCES = { credential: ['create', 'update', 'move', 'delete'] as const, sourceControl: ['pull'] as const, workflowTags: ['update', 'list'] as const, + dataTable: ['create', 'read', 'update', 'delete', 'list'] as const, + dataTableRow: ['create', 'read', 'update', 'delete', 'upsert'] as const, } as const; export const PROJECT_OWNER_ROLE_SLUG = 'project:personalOwner'; diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 3a82149ecf6..97bac85808b 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -1,4 +1,10 @@ -import { isApiKeyScope, type ApiKeyScope, type AuthPrincipal, type GlobalRole } from './types.ee'; +import { + isApiKeyScope, + type ApiKeyScope, + type AuthPrincipal, + type GlobalRole, + type Scope, +} from './types.ee'; export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'user:read', @@ -40,6 +46,16 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'credential:update', 'credential:move', 'credential:delete', + 'dataTable:create', + 'dataTable:read', + 'dataTable:update', + 'dataTable:delete', + 'dataTable:list', + 'dataTableRow:create', + 'dataTableRow:read', + 'dataTableRow:update', + 'dataTableRow:delete', + 'dataTableRow:upsert', ]; export const ADMIN_API_KEY_SCOPES: ApiKeyScope[] = OWNER_API_KEY_SCOPES; @@ -67,6 +83,16 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [ 'credential:update', 'credential:move', 'credential:delete', + 'dataTable:create', + 'dataTable:read', + 'dataTable:update', + 'dataTable:delete', + 'dataTable:list', + 'dataTableRow:create', + 'dataTableRow:read', + 'dataTableRow:update', + 'dataTableRow:delete', + 'dataTableRow:upsert', ]; export const CHAT_USER_API_KEY_SCOPES: ApiKeyScope[] = []; @@ -96,6 +122,16 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [ 'credential:update', 'credential:move', 'credential:delete', + 'dataTable:create', + 'dataTable:read', + 'dataTable:update', + 'dataTable:delete', + 'dataTable:list', + 'dataTableRow:create', + 'dataTableRow:read', + 'dataTableRow:update', + 'dataTableRow:delete', + 'dataTableRow:upsert', ]; const MAP_ROLE_SCOPES: Record = { @@ -112,8 +148,7 @@ export const getApiKeyScopesForRole = (user: AuthPrincipal) => { return [ ...new Set( - user.role.scopes - .map((scope) => scope.slug) + (user.role.scopes.map((scope) => scope.slug) as Array) .concat(API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT) .filter(isApiKeyScope), ), diff --git a/packages/@n8n/permissions/src/types.ee.ts b/packages/@n8n/permissions/src/types.ee.ts index 3fb4ea0413b..6145dcbc997 100644 --- a/packages/@n8n/permissions/src/types.ee.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -119,7 +119,7 @@ type AllApiKeyScopesObject = { export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources]; -export function isApiKeyScope(scope: Scope): scope is ApiKeyScope { +export function isApiKeyScope(scope: Scope | ApiKeyScope): scope is ApiKeyScope { // We are casting with as for runtime type checking return ALL_API_KEY_SCOPES.has(scope as ApiKeyScope); } diff --git a/packages/cli/src/permissions.ee/check-access.ts b/packages/cli/src/permissions.ee/check-access.ts index 0974b232f15..69ea698a6d9 100644 --- a/packages/cli/src/permissions.ee/check-access.ts +++ b/packages/cli/src/permissions.ee/check-access.ts @@ -1,5 +1,6 @@ import type { User } from '@n8n/db'; import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db'; +import { ModuleRegistry } from '@n8n/backend-common'; import { Container } from '@n8n/di'; import { hasGlobalScope, type Scope } from '@n8n/permissions'; import { UnexpectedError } from 'n8n-workflow'; @@ -24,7 +25,13 @@ export async function userHasScopes( credentialId, workflowId, projectId, - }: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */, + dataTableId, + }: { + credentialId?: string; + workflowId?: string; + projectId?: string; + dataTableId?: string; + } /* only one */, ): Promise { if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return true; @@ -99,9 +106,29 @@ export async function userHasScopes( ); } + if (dataTableId) { + const moduleRegistry = Container.get(ModuleRegistry); + if (!moduleRegistry.isActive('data-table')) { + throw new NotFoundError(`Data table with ID "${dataTableId}" not found.`); + } + + const { DataTableRepository } = await import('@/modules/data-table/data-table.repository'); + const dataTable = await Container.get(DataTableRepository).findOne({ + where: { id: dataTableId }, + relations: ['project'], + }); + + if (!dataTable) { + throw new NotFoundError(`Data table with ID "${dataTableId}" not found.`); + } + + // Data tables don't have resource-level roles, only project-level access + return userProjectIds.includes(dataTable.project.id); + } + if (projectId) return userProjectIds.includes(projectId); throw new UnexpectedError( - "`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.", + "`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, `dataTableId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.", ); } diff --git a/packages/cli/src/public-api/index.ts b/packages/cli/src/public-api/index.ts index 77885fa8c92..6ebd8583a30 100644 --- a/packages/cli/src/public-api/index.ts +++ b/packages/cli/src/public-api/index.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; -import type { Router } from 'express'; +import type { ErrorRequestHandler, Router } from 'express'; import express from 'express'; import type { HttpError } from 'express-openapi-validator/dist/framework/types'; import fs from 'fs/promises'; @@ -51,9 +51,22 @@ async function createApiRouter( }); const { middleware: openApiValidatorMiddleware } = await import('express-openapi-validator'); + + // Error handler specifically for JSON parsing - must come immediately after express.json() + const jsonParseErrorHandler: ErrorRequestHandler = (error, _req, res, next) => { + if (error instanceof SyntaxError && 'body' in error) { + res.status(400).json({ + message: 'Invalid JSON in request body', + }); + return; + } + next(error); + }; + apiController.use( `/${publicApiEndpoint}/${version}`, express.json(), + jsonParseErrorHandler, openApiValidatorMiddleware({ apiSpec: openApiSpecPath, operationHandlers: handlersDirectory, @@ -79,6 +92,13 @@ async function createApiRouter( } }, }, + nanoid: { + type: 'string', + validate: (id: string) => { + // Nanoids in n8n are 16 characters long and use alphanumeric characters + return /^[A-Za-z0-9]{16}$/.test(id); + }, + }, }, validateSecurity: { handlers: { diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index f0e14e66fb1..8df0f2141dd 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -1,5 +1,12 @@ import type { AuthenticatedRequest, TagEntity, WorkflowEntity } from '@n8n/db'; import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow'; +import type { + AddDataTableRowsDto, + CreateDataTableDto, + UpdateDataTableDto, + UpdateDataTableRowDto, + UpsertDataTableRowDto, +} from '@n8n/api-types'; import type { AuthlessRequest } from '@/requests'; import type { Risk } from '@/security-audit/types'; @@ -193,6 +200,64 @@ export interface IJsonSchema { required: string[]; } +// ---------------------------------- +// /data-tables +// ---------------------------------- + +export declare namespace DataTableRequest { + type List = AuthenticatedRequest< + {}, + {}, + {}, + { + limit?: number; + cursor?: string; + offset?: number; + filter?: string; + sortBy?: string; + } + >; + + type Create = AuthenticatedRequest<{}, {}, CreateDataTableDto, {}>; + + type Get = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>; + + type Update = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableDto, {}>; + + type Delete = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>; + + type GetRows = AuthenticatedRequest< + { dataTableId: string }, + {}, + {}, + { + limit?: number; + cursor?: string; + offset?: number; + filter?: string; + sortBy?: string; + search?: string; + } + >; + + type InsertRows = AuthenticatedRequest<{ dataTableId: string }, {}, AddDataTableRowsDto, {}>; + + type UpdateRows = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableRowDto, {}>; + + type UpsertRow = AuthenticatedRequest<{ dataTableId: string }, {}, UpsertDataTableRowDto, {}>; + + type DeleteRows = AuthenticatedRequest< + { dataTableId: string }, + {}, + {}, + { + filter?: string; + returnData?: string | boolean; + dryRun?: string | boolean; + } + >; +} + // ---------------------------------- // /audit // ---------------------------------- diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts new file mode 100644 index 00000000000..dd799abe8dd --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts @@ -0,0 +1,738 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import { ProjectRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import type { Response } from 'express'; + +import { DataTableRepository } from '@/modules/data-table/data-table.repository'; +import { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import type { DataTableRequest } from '@/public-api/types'; +import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware'; + +// Mock middleware before requiring handler +const mockMiddleware = jest.fn(async (_req, _res, next) => next()) as any; +jest.spyOn(middlewares, 'apiKeyHasScope').mockReturnValue(mockMiddleware); +jest.spyOn(middlewares, 'projectScope').mockReturnValue(mockMiddleware); +jest.spyOn(middlewares, 'validCursor').mockReturnValue(mockMiddleware); + +const handler = require('../data-tables.rows.handler'); + +describe('DataTable Handler', () => { + let mockDataTableService: jest.Mocked; + let mockDataTableRepository: jest.Mocked; + let mockProjectRepository: jest.Mocked; + let mockResponse: Partial; + + const projectId = 'test-project-id'; + const dataTableId = 'test-data-table-id'; + const userId = 'test-user-id'; + + beforeEach(() => { + mockDataTableService = mockInstance(DataTableService); + mockDataTableRepository = mockInstance(DataTableRepository); + mockProjectRepository = mockInstance(ProjectRepository); + + jest.spyOn(Container, 'get').mockImplementation((serviceClass) => { + if (serviceClass === DataTableService) { + return mockDataTableService as any; + } + if (serviceClass === DataTableRepository) { + return mockDataTableRepository as any; + } + if (serviceClass === ProjectRepository) { + return mockProjectRepository as any; + } + return {} as any; + }); + + mockDataTableRepository.findOne.mockResolvedValue({ + id: dataTableId, + project: { id: projectId }, + } as any); + + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as any); + + mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }; + + jest.clearAllMocks(); + }); + + describe('getDataTableRows', () => { + it('should retrieve rows successfully', async () => { + // Arrange + const req = { + params: { dataTableId }, + query: { offset: '0', limit: '100' }, + user: { id: userId }, + } as unknown as DataTableRequest.GetRows; + + const mockRows = [ + { id: 1, name: 'Test Row 1', createdAt: new Date(), updatedAt: new Date() }, + { id: 2, name: 'Test Row 2', createdAt: new Date(), updatedAt: new Date() }, + ]; + + mockDataTableService.getManyRowsAndCount.mockResolvedValue({ + data: mockRows, + count: 2, + }); + + // Act + await handler.getDataTableRows[3](req, mockResponse as Response); + + // Assert + expect(mockDataTableRepository.findOne).toHaveBeenCalledWith({ + where: { id: dataTableId }, + relations: ['project'], + }); + expect(mockDataTableService.getManyRowsAndCount).toHaveBeenCalledWith( + dataTableId, + projectId, + { skip: 0, take: 100, filter: undefined, sortBy: undefined, search: undefined }, + ); + const callArg = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(callArg).toHaveProperty('data', mockRows); + expect(callArg).toHaveProperty('nextCursor'); + }); + + it('should handle filter, sortBy, and search parameters', async () => { + // Arrange + const filterStr = + '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"active"}]}'; + const req = { + params: { dataTableId }, + query: { + offset: '10', + limit: '50', + filter: filterStr, + sortBy: 'createdAt:desc', + search: 'test', + }, + user: { id: userId }, + } as unknown as DataTableRequest.GetRows; + + mockDataTableService.getManyRowsAndCount.mockResolvedValue({ + data: [], + count: 0, + }); + + // Act + await handler.getDataTableRows[3](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.getManyRowsAndCount).toHaveBeenCalledWith( + dataTableId, + projectId, + { + skip: 10, + take: 50, + filter: JSON.parse(filterStr), + sortBy: ['createdAt', 'DESC'], + search: 'test', + }, + ); + }); + + it('should return 404 when data table not found', async () => { + // Arrange + const req = { + params: { dataTableId }, + query: { offset: '0', limit: '100' }, + user: { id: userId }, + } as unknown as DataTableRequest.GetRows; + + mockDataTableRepository.findOne.mockRejectedValue(new DataTableNotFoundError(dataTableId)); + + // Act + await handler.getDataTableRows[3](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining(dataTableId), + }); + }); + + it('should return 400 for validation errors', async () => { + // Arrange + const req = { + params: { dataTableId }, + query: { offset: '0', limit: '100', filter: 'invalid-json' }, + user: { id: userId }, + } as unknown as DataTableRequest.GetRows; + + // Act + await handler.getDataTableRows[3](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining('Invalid'), + }); + }); + }); + + describe('insertDataTableRows', () => { + it('should insert rows and return count', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + data: [{ name: 'New Row' }], + returnType: 'count', + }, + user: { id: userId }, + } as unknown as DataTableRequest.InsertRows; + + mockDataTableService.insertRows.mockResolvedValue({ count: 1 } as any); + + // Act + await handler.insertDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.insertRows).toHaveBeenCalledWith( + dataTableId, + projectId, + [{ name: 'New Row' }], + 'count', + ); + expect(mockResponse.json).toHaveBeenCalledWith({ count: 1 }); + }); + + it('should insert rows and return IDs', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + data: [{ name: 'Row 1' }, { name: 'Row 2' }], + returnType: 'id', + }, + user: { id: userId }, + } as unknown as DataTableRequest.InsertRows; + + mockDataTableService.insertRows.mockResolvedValue([1, 2] as any); + + // Act + await handler.insertDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.insertRows).toHaveBeenCalledWith( + dataTableId, + projectId, + [{ name: 'Row 1' }, { name: 'Row 2' }], + 'id', + ); + expect(mockResponse.json).toHaveBeenCalledWith([1, 2]); + }); + + it('should insert rows and return full rows', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + data: [{ name: 'Test' }], + returnType: 'all', + }, + user: { id: userId }, + } as unknown as DataTableRequest.InsertRows; + + const mockRow = { id: 1, name: 'Test', createdAt: new Date(), updatedAt: new Date() }; + mockDataTableService.insertRows.mockResolvedValue([mockRow]); + + // Act + await handler.insertDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.json).toHaveBeenCalledWith([mockRow]); + }); + + it('should return 404 when data table not found', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { data: [{ name: 'Test' }], returnType: 'count' }, + user: { id: userId }, + } as unknown as DataTableRequest.InsertRows; + + mockDataTableRepository.findOne.mockRejectedValue(new DataTableNotFoundError(dataTableId)); + + // Act + await handler.insertDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(404); + }); + }); + + describe('updateDataTableRows', () => { + it('should update rows and return true when returnData is false', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, + data: { status: 'updated' }, + returnData: false, + dryRun: false, + }, + user: { id: userId }, + } as unknown as DataTableRequest.UpdateRows; + + mockDataTableService.updateRows.mockResolvedValue(true); + + // Act + await handler.updateDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.updateRows).toHaveBeenCalledWith( + dataTableId, + projectId, + { + filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, + data: { status: 'updated' }, + }, + false, + false, + ); + expect(mockResponse.json).toHaveBeenCalledWith(true); + }); + + it('should update rows and return updated rows when returnData is true', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, + data: { status: 'updated' }, + returnData: true, + dryRun: false, + }, + user: { id: userId }, + } as unknown as DataTableRequest.UpdateRows; + + const mockRow = { id: 1, status: 'updated', createdAt: new Date(), updatedAt: new Date() }; + mockDataTableService.updateRows.mockResolvedValue([mockRow] as any); + + // Act + await handler.updateDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.json).toHaveBeenCalledWith([mockRow]); + }); + + it('should support dry run mode', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + filter: { + type: 'and', + filters: [{ columnName: 'id', condition: 'eq', value: 1 }], + }, + data: { status: 'test' }, + returnData: true, + dryRun: true, + }, + user: { id: userId }, + } as unknown as DataTableRequest.UpdateRows; + + const mockDryRunResult = [ + { id: 1, status: 'old', dryRunState: 'before' }, + { id: 1, status: 'test', dryRunState: 'after' }, + ]; + mockDataTableService.updateRows.mockResolvedValue(mockDryRunResult as any); + + // Act + await handler.updateDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.updateRows).toHaveBeenCalledWith( + dataTableId, + projectId, + expect.any(Object), + true, + true, + ); + }); + }); + + describe('upsertDataTableRow', () => { + it('should upsert row and return true when returnData is false', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { email: 'test@example.com', name: 'Test User' }, + returnData: false, + dryRun: false, + }, + user: { id: userId }, + } as unknown as DataTableRequest.UpsertRow; + + mockDataTableService.upsertRow.mockResolvedValue(true); + + // Act + await handler.upsertDataTableRow[2](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.upsertRow).toHaveBeenCalledWith( + dataTableId, + projectId, + expect.objectContaining({ + filter: expect.any(Object), + data: { email: 'test@example.com', name: 'Test User' }, + }), + false, + false, + ); + expect(mockResponse.json).toHaveBeenCalledWith(true); + }); + + it('should upsert row and return upserted row when returnData is true', async () => { + // Arrange + const req = { + params: { dataTableId }, + body: { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { email: 'test@example.com', name: 'Test User' }, + returnData: true, + dryRun: false, + }, + user: { id: userId }, + } as unknown as DataTableRequest.UpsertRow; + + const mockRow = { + id: 1, + email: 'test@example.com', + name: 'Test User', + createdAt: new Date(), + updatedAt: new Date(), + }; + mockDataTableService.upsertRow.mockResolvedValue(mockRow as any); + + // Act + await handler.upsertDataTableRow[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.json).toHaveBeenCalledWith(mockRow); + }); + }); + + describe('deleteDataTableRows', () => { + it('should delete rows and return true when returnData is false', async () => { + // Arrange + const filterStr = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'archived' }], + }); + const req = { + params: { dataTableId }, + query: { + filter: filterStr, + returnData: 'false', + dryRun: 'false', + }, + user: { id: userId }, + } as unknown as DataTableRequest.DeleteRows; + + mockDataTableService.deleteRows.mockResolvedValue(true); + + // Act + await handler.deleteDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.deleteRows).toHaveBeenCalledWith( + dataTableId, + projectId, + { + filter: JSON.parse(filterStr), + }, + false, + false, + ); + expect(mockResponse.json).toHaveBeenCalledWith(true); + }); + + it('should delete rows and return deleted rows when returnData is true', async () => { + // Arrange + const filterStr = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'id', condition: 'eq', value: 1 }], + }); + const req = { + params: { dataTableId }, + query: { + filter: filterStr, + returnData: 'true', + dryRun: 'false', + }, + user: { id: userId }, + } as unknown as DataTableRequest.DeleteRows; + + const mockRow = { id: 1, name: 'Deleted', createdAt: new Date(), updatedAt: new Date() }; + mockDataTableService.deleteRows.mockResolvedValue([mockRow] as any); + + // Act + await handler.deleteDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.json).toHaveBeenCalledWith([mockRow]); + }); + + it('should return 400 when filter is missing', async () => { + // Arrange + const req = { + params: { dataTableId }, + query: {}, + user: { id: userId }, + } as unknown as DataTableRequest.DeleteRows; + + // Act + await handler.deleteDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Required', + }); + }); + + it('should support dry run mode', async () => { + // Arrange + const filterStr = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'test' }], + }); + const req = { + params: { dataTableId }, + query: { + filter: filterStr, + returnData: 'true', + dryRun: 'true', + }, + user: { id: userId }, + } as unknown as DataTableRequest.DeleteRows; + + const mockRows = [{ id: 1, status: 'test', createdAt: new Date(), updatedAt: new Date() }]; + mockDataTableService.deleteRows.mockResolvedValue(mockRows as any); + + // Act + await handler.deleteDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockDataTableService.deleteRows).toHaveBeenCalledWith( + dataTableId, + projectId, + expect.objectContaining({ filter: expect.any(Object) }), + true, + true, + ); + }); + }); + + describe('Security - Cross-Project Access', () => { + const otherUserDataTableId = 'other-user-data-table-id'; + + it('should return 404 when trying to get rows from another users data table', async () => { + // Arrange + const req = { + params: { dataTableId: otherUserDataTableId }, + query: { offset: '0', limit: '100' }, + user: { id: userId }, + } as unknown as DataTableRequest.GetRows; + + // User's personal project is returned + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as any); + + // But the data table belongs to another project, so getProjectIdForDataTable throws + mockDataTableRepository.findOne.mockRejectedValue( + new DataTableNotFoundError(otherUserDataTableId), + ); + + // Act + await handler.getDataTableRows[3](req, mockResponse as Response); + + // Assert + expect(mockDataTableRepository.findOne).toHaveBeenCalledWith({ + where: { id: otherUserDataTableId }, + relations: ['project'], + }); + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining(otherUserDataTableId), + }); + }); + + it('should return 404 when trying to insert rows into another users data table', async () => { + // Arrange + const req = { + params: { dataTableId: otherUserDataTableId }, + body: { + data: [{ name: 'Malicious Row' }], + returnType: 'count', + }, + user: { id: userId }, + } as unknown as DataTableRequest.InsertRows; + + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as any); + + mockDataTableService.insertRows.mockRejectedValue( + new DataTableNotFoundError(otherUserDataTableId), + ); + + // Act + await handler.insertDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining(otherUserDataTableId), + }); + }); + + it('should return 404 when trying to update rows in another users data table', async () => { + // Arrange + const req = { + params: { dataTableId: otherUserDataTableId }, + body: { + filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, + data: { status: 'hacked' }, + returnData: false, + dryRun: false, + }, + user: { id: userId }, + } as unknown as DataTableRequest.UpdateRows; + + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as any); + + mockDataTableService.updateRows.mockRejectedValue( + new DataTableNotFoundError(otherUserDataTableId), + ); + + // Act + await handler.updateDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining(otherUserDataTableId), + }); + }); + + it('should return 404 when trying to upsert row in another users data table', async () => { + // Arrange + const req = { + params: { dataTableId: otherUserDataTableId }, + body: { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'malicious@example.com' }], + }, + data: { email: 'malicious@example.com', name: 'Hacker' }, + returnData: false, + dryRun: false, + }, + user: { id: userId }, + } as unknown as DataTableRequest.UpsertRow; + + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as any); + + mockDataTableService.upsertRow.mockRejectedValue( + new DataTableNotFoundError(otherUserDataTableId), + ); + + // Act + await handler.upsertDataTableRow[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining(otherUserDataTableId), + }); + }); + + it('should return 404 when trying to delete rows from another users data table', async () => { + // Arrange + const filterStr = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'id', condition: 'eq', value: 1 }], + }); + const req = { + params: { dataTableId: otherUserDataTableId }, + query: { + filter: filterStr, + returnData: 'false', + dryRun: 'false', + }, + user: { id: userId }, + } as unknown as DataTableRequest.DeleteRows; + + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as any); + + mockDataTableService.deleteRows.mockRejectedValue( + new DataTableNotFoundError(otherUserDataTableId), + ); + + // Act + await handler.deleteDataTableRows[2](req, mockResponse as Response); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining(otherUserDataTableId), + }); + }); + + it('should not leak information about data table existence in error messages', async () => { + // Arrange + const nonExistentDataTableId = 'non-existent-table-id'; + const req = { + params: { dataTableId: nonExistentDataTableId }, + query: { offset: '0', limit: '100' }, + user: { id: userId }, + } as unknown as DataTableRequest.GetRows; + + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as any); + + mockDataTableRepository.findOne.mockRejectedValue( + new DataTableNotFoundError(nonExistentDataTableId), + ); + + // Act + await handler.getDataTableRows[3](req, mockResponse as Response); + + // Assert + // The error message should be the same whether: + // 1. The table doesn't exist at all + // 2. The table exists but belongs to another user's project + // This prevents information leakage + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: expect.stringContaining(nonExistentDataTableId), + }); + }); + }); +}); diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts new file mode 100644 index 00000000000..1c602a8b39a --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts @@ -0,0 +1,222 @@ +import { + PublicApiListDataTableQueryDto, + CreateDataTableDto, + UpdateDataTableDto, +} from '@n8n/api-types'; +import { ProjectRepository } from '@n8n/db'; +import { DataTableRepository } from '@/modules/data-table/data-table.repository'; +import { Container } from '@n8n/di'; +import type express from 'express'; + +import type { DataTableRequest } from '../../../types'; +import { + apiKeyHasScope, + projectScope, + validCursor, +} from '../../shared/middlewares/global.middleware'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; +import { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import { DataTableNameConflictError } from '@/modules/data-table/errors/data-table-name-conflict.error'; +import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; + +const handleError = (error: unknown, res: express.Response): express.Response => { + if (error instanceof DataTableNotFoundError) { + return res.status(404).json({ message: error.message }); + } + if (error instanceof DataTableNameConflictError) { + return res.status(409).json({ message: error.message }); + } + if (error instanceof DataTableValidationError) { + return res.status(400).json({ message: error.message }); + } + if (error instanceof Error) { + return res.status(400).json({ message: error.message }); + } + throw error; +}; + +/** + * Convert all query parameter values to strings for DTO validation. + * Express/Supertest may parse some values as numbers/booleans. + */ +const stringifyQuery = (query: Record): Record => { + const result: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + result[key] = String(value); + } + } + return result; +}; + +/** + * Gets the project ID for a data table. + * Called AFTER projectScope middleware has validated access. + */ +const getProjectIdForDataTable = async (dataTableId: string): Promise => { + const dataTable = await Container.get(DataTableRepository).findOne({ + where: { id: dataTableId }, + relations: ['project'], + }); + + if (!dataTable) { + throw new DataTableNotFoundError(dataTableId); + } + + return dataTable.project.id; +}; + +export = { + listDataTables: [ + apiKeyHasScope('dataTable:list'), + validCursor, + async (req: DataTableRequest.List, res: express.Response): Promise => { + try { + const payload = PublicApiListDataTableQueryDto.safeParse(stringifyQuery(req.query)); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid query parameters', + }); + } + + const { offset, limit, filter, sortBy } = payload.data; + + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + req.user.id, + ); + + const providedFilter = filter ?? {}; + const result = await Container.get(DataTableService).getManyAndCount({ + skip: offset, + take: limit, + filter: { ...providedFilter, projectId: project.id }, + sortBy, + }); + + const data = result.data.map(({ project: _project, ...rest }) => rest); + + return res.json({ + data, + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: result.count, + }), + }); + } catch (error) { + return handleError(error, res); + } + }, + ], + + createDataTable: [ + apiKeyHasScope('dataTable:create'), + async (req: DataTableRequest.Create, res: express.Response): Promise => { + try { + const payload = CreateDataTableDto.safeParse(req.body); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid request body', + }); + } + + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + req.user.id, + ); + + const result = await Container.get(DataTableService).createDataTable( + project.id, + payload.data, + ); + + const { project: _project, ...dataTable } = result; + + return res.status(201).json(dataTable); + } catch (error) { + return handleError(error, res); + } + }, + ], + + getDataTable: [ + apiKeyHasScope('dataTable:read'), + projectScope('dataTable:read', 'dataTable'), + async (req: DataTableRequest.Get, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const projectId = await getProjectIdForDataTable(dataTableId); + + const result = await Container.get(DataTableRepository).findOne({ + where: { id: dataTableId, project: { id: projectId } }, + relations: ['project', 'columns'], + }); + + if (!result) { + throw new DataTableNotFoundError(dataTableId); + } + + const { project: _project, ...dataTable } = result; + + return res.json(dataTable); + } catch (error) { + return handleError(error, res); + } + }, + ], + + updateDataTable: [ + apiKeyHasScope('dataTable:update'), + projectScope('dataTable:update', 'dataTable'), + async (req: DataTableRequest.Update, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const payload = UpdateDataTableDto.safeParse(req.body); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid request body', + }); + } + + const projectId = await getProjectIdForDataTable(dataTableId); + + await Container.get(DataTableService).updateDataTable(dataTableId, projectId, payload.data); + + const result = await Container.get(DataTableRepository).findOne({ + where: { id: dataTableId, project: { id: projectId } }, + relations: ['project', 'columns'], + }); + + if (!result) { + throw new DataTableNotFoundError(dataTableId); + } + + const { project: _project, ...dataTable } = result; + + return res.json(dataTable); + } catch (error) { + return handleError(error, res); + } + }, + ], + + deleteDataTable: [ + apiKeyHasScope('dataTable:delete'), + projectScope('dataTable:delete', 'dataTable'), + async (req: DataTableRequest.Delete, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const projectId = await getProjectIdForDataTable(dataTableId); + + await Container.get(DataTableService).deleteDataTable(dataTableId, projectId); + + return res.status(204).send(); + } catch (error) { + return handleError(error, res); + } + }, + ], +}; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts new file mode 100644 index 00000000000..591b3e38fbd --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts @@ -0,0 +1,238 @@ +import { + PublicApiListDataTableContentQueryDto, + AddDataTableRowsDto, + UpdateDataTableRowDto, + UpsertDataTableRowDto, + DeleteDataTableRowsDto, +} from '@n8n/api-types'; +import { DataTableRepository } from '@/modules/data-table/data-table.repository'; +import { Container } from '@n8n/di'; +import type express from 'express'; + +import type { DataTableRequest } from '../../../types'; +import { + apiKeyHasScope, + projectScope, + validCursor, +} from '../../shared/middlewares/global.middleware'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; +import { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; + +const handleError = (error: unknown, res: express.Response): express.Response => { + if (error instanceof DataTableNotFoundError) { + return res.status(404).json({ message: error.message }); + } + if (error instanceof DataTableValidationError) { + return res.status(400).json({ message: error.message }); + } + if (error instanceof Error) { + return res.status(400).json({ message: error.message }); + } + throw error; +}; + +/** + * Convert all query parameter values to strings for DTO validation. + * Express/Supertest may parse some values as numbers/booleans. + */ +const stringifyQuery = (query: Record): Record => { + const result: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + result[key] = String(value); + } + } + return result; +}; + +/** + * Gets the project ID for a data table. + * Called AFTER projectScope middleware has validated access. + */ +const getProjectIdForDataTable = async (dataTableId: string): Promise => { + const dataTable = await Container.get(DataTableRepository).findOne({ + where: { id: dataTableId }, + relations: ['project'], + }); + + if (!dataTable) { + throw new DataTableNotFoundError(dataTableId); + } + + return dataTable.project.id; +}; + +export = { + getDataTableRows: [ + apiKeyHasScope('dataTableRow:read'), + projectScope('dataTable:readRow', 'dataTable'), + validCursor, + async (req: DataTableRequest.GetRows, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const payload = PublicApiListDataTableContentQueryDto.safeParse(stringifyQuery(req.query)); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid query parameters', + }); + } + + const { offset, limit, filter, sortBy, search } = payload.data; + + const projectId = await getProjectIdForDataTable(dataTableId); + + const result = await Container.get(DataTableService).getManyRowsAndCount( + dataTableId, + projectId, + { + skip: offset, + take: limit, + filter, + sortBy, + search, + }, + ); + + return res.json({ + data: result.data, + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: result.count, + }), + }); + } catch (error) { + return handleError(error, res); + } + }, + ], + + insertDataTableRows: [ + apiKeyHasScope('dataTableRow:create'), + projectScope('dataTable:writeRow', 'dataTable'), + async (req: DataTableRequest.InsertRows, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const payload = AddDataTableRowsDto.safeParse(req.body); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid request body', + }); + } + + const projectId = await getProjectIdForDataTable(dataTableId); + + const result = await Container.get(DataTableService).insertRows( + dataTableId, + projectId, + payload.data.data, + payload.data.returnType, + ); + + return res.json(result); + } catch (error) { + return handleError(error, res); + } + }, + ], + + updateDataTableRows: [ + apiKeyHasScope('dataTableRow:update'), + projectScope('dataTable:writeRow', 'dataTable'), + async (req: DataTableRequest.UpdateRows, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const payload = UpdateDataTableRowDto.safeParse(req.body); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid request body', + }); + } + + const projectId = await getProjectIdForDataTable(dataTableId); + const service = Container.get(DataTableService); + const { filter, data, returnData = false, dryRun = false } = payload.data; + const params = { filter, data }; + + const result = dryRun + ? await service.updateRows(dataTableId, projectId, params, returnData, true) + : returnData + ? await service.updateRows(dataTableId, projectId, params, true, false) + : await service.updateRows(dataTableId, projectId, params, false, false); + + return res.json(result); + } catch (error) { + return handleError(error, res); + } + }, + ], + + upsertDataTableRow: [ + apiKeyHasScope('dataTableRow:upsert'), + projectScope('dataTable:writeRow', 'dataTable'), + async (req: DataTableRequest.UpsertRow, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const payload = UpsertDataTableRowDto.safeParse(req.body); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid request body', + }); + } + + const projectId = await getProjectIdForDataTable(dataTableId); + const service = Container.get(DataTableService); + const { filter, data, returnData = false, dryRun = false } = payload.data; + const params = { filter, data }; + + const result = dryRun + ? await service.upsertRow(dataTableId, projectId, params, returnData, true) + : returnData + ? await service.upsertRow(dataTableId, projectId, params, true, false) + : await service.upsertRow(dataTableId, projectId, params, false, false); + + return res.json(result); + } catch (error) { + return handleError(error, res); + } + }, + ], + + deleteDataTableRows: [ + apiKeyHasScope('dataTableRow:delete'), + projectScope('dataTable:writeRow', 'dataTable'), + async (req: DataTableRequest.DeleteRows, res: express.Response): Promise => { + try { + const { dataTableId } = req.params; + + const payload = DeleteDataTableRowsDto.safeParse(stringifyQuery(req.query)); + if (!payload.success) { + return res.status(400).json({ + message: payload.error.errors[0]?.message || 'Invalid query parameters', + }); + } + + const projectId = await getProjectIdForDataTable(dataTableId); + const service = Container.get(DataTableService); + const { filter, returnData = false, dryRun = false } = payload.data; + const params = { filter }; + + const result = dryRun + ? await service.deleteRows(dataTableId, projectId, params, returnData, true) + : returnData + ? await service.deleteRows(dataTableId, projectId, params, true, false) + : await service.deleteRows(dataTableId, projectId, params, false, false); + + return res.json(result); + } catch (error) { + return handleError(error, res); + } + }, + ], +}; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml new file mode 100644 index 00000000000..76b81c860b3 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml @@ -0,0 +1,51 @@ +delete: + x-eov-operation-id: deleteDataTableRows + x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler + tags: + - DataTable + summary: Delete rows from a data table + description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data. + operationId: delete-data-table-rows + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + - name: filter + in: query + required: true + description: JSON string of filter conditions. Required to prevent accidental deletion of all data. + schema: + type: string + format: jsonString + example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}' + - name: returnData + in: query + description: If true, return the deleted rows; if false, return true on success + schema: + type: boolean + default: false + - name: dryRun + in: query + description: If true, preview which rows would be deleted without actually deleting them + schema: + type: boolean + default: false + responses: + '200': + description: Rows deleted successfully + content: + application/json: + schema: + oneOf: + - type: boolean + description: True when returnData is false + - type: array + items: + $ref: '../schemas/dataTableRow.yml' + description: Deleted rows when returnData is true + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + security: + - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml new file mode 100644 index 00000000000..cbf6f9b3409 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml @@ -0,0 +1,49 @@ +patch: + x-eov-operation-id: updateDataTableRows + x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler + tags: + - DataTable + summary: Update rows in a data table + description: Update rows matching filter conditions in a data table. + operationId: update-data-table-rows + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/updateRowsRequest.yml' + example: + filter: + type: 'and' + filters: + - columnName: 'status' + condition: 'eq' + value: 'pending' + data: + status: 'completed' + updatedBy: 'admin' + returnData: false + dryRun: false + responses: + '200': + description: Rows updated successfully + content: + application/json: + schema: + oneOf: + - type: boolean + description: True when returnData is false + - type: array + items: + $ref: '../schemas/dataTableRow.yml' + description: Updated rows when returnData is true + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + security: + - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.upsert.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.upsert.yml new file mode 100644 index 00000000000..161a56abbf3 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.upsert.yml @@ -0,0 +1,49 @@ +post: + x-eov-operation-id: upsertDataTableRow + x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler + tags: + - DataTable + summary: Upsert a row in a data table + description: Update an existing row or insert a new one if no row matches the filter conditions. + operationId: upsert-data-table-row + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/upsertRowRequest.yml' + example: + filter: + type: 'and' + filters: + - columnName: 'email' + condition: 'eq' + value: 'user@example.com' + data: + email: 'user@example.com' + name: 'Updated Name' + status: 'active' + returnData: true + dryRun: false + responses: + '200': + description: Row upserted successfully + content: + application/json: + schema: + oneOf: + - type: boolean + description: True when returnData is false + - allOf: + - $ref: '../schemas/dataTableRow.yml' + description: Upserted row when returnData is true + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + security: + - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.yml new file mode 100644 index 00000000000..d705edd4bb0 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.yml @@ -0,0 +1,99 @@ +get: + x-eov-operation-id: getDataTableRows + x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler + tags: + - DataTable + summary: Retrieve rows from a data table + description: Query and retrieve rows from a data table with optional filtering, sorting, and pagination. + operationId: get-data-table-rows + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + - name: filter + in: query + description: JSON string of filter conditions + schema: + type: string + format: jsonString + example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"active"}]}' + - name: sortBy + in: query + description: 'Sort format: columnName:asc or columnName:desc' + schema: + type: string + example: 'createdAt:desc' + - name: search + in: query + description: Search text across all string columns + schema: + type: string + responses: + '200': + description: Successfully retrieved rows + content: + application/json: + schema: + $ref: '../schemas/dataTableRowList.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + security: + - ApiKeyAuth: [] + +post: + x-eov-operation-id: insertDataTableRows + x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler + tags: + - DataTable + summary: Insert rows into a data table + description: Insert one or more rows into a data table. + operationId: insert-data-table-rows + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/insertRowsRequest.yml' + example: + data: + - name: 'John Doe' + email: 'john@example.com' + age: 30 + - name: 'Jane Smith' + email: 'jane@example.com' + age: 25 + returnType: 'all' + responses: + '200': + description: Rows inserted successfully + content: + application/json: + schema: + oneOf: + - type: object + properties: + count: + type: integer + description: Number of rows inserted (when returnType is 'count') + - type: array + items: + type: integer + description: Array of inserted row IDs (when returnType is 'id') + - type: array + items: + $ref: '../schemas/dataTableRow.yml' + description: Array of inserted rows (when returnType is 'all') + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + security: + - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.yml new file mode 100644 index 00000000000..3dd21103391 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.yml @@ -0,0 +1,79 @@ +get: + x-eov-operation-id: getDataTable + x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler + tags: + - DataTable + summary: Get a data table + description: Retrieve a specific data table by ID. + operationId: get-data-table + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + responses: + '200': + description: Successfully retrieved data table + content: + application/json: + schema: + $ref: '../schemas/dataTable.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + security: + - ApiKeyAuth: [] + +patch: + x-eov-operation-id: updateDataTable + x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler + tags: + - DataTable + summary: Update a data table + description: Update a data table's name. + operationId: update-data-table + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/updateDataTableRequest.yml' + example: + name: 'updated-customers' + responses: + '200': + description: Data table updated successfully + content: + application/json: + schema: + $ref: '../schemas/dataTable.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + '409': + $ref: '../../../../shared/spec/responses/conflict.yml' + security: + - ApiKeyAuth: [] + +delete: + x-eov-operation-id: deleteDataTable + x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler + tags: + - DataTable + summary: Delete a data table + description: Delete a data table. This will also delete all rows in the table. + operationId: delete-data-table + parameters: + - $ref: '../schemas/parameters/dataTableId.yml' + responses: + '204': + description: Data table deleted successfully + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + security: + - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml new file mode 100644 index 00000000000..bea52d173fb --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml @@ -0,0 +1,76 @@ +get: + x-eov-operation-id: listDataTables + x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler + tags: + - DataTable + summary: List all data tables + description: Retrieve a list of all data tables with optional filtering, sorting, and pagination. + operationId: list-data-tables + parameters: + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + - name: filter + in: query + description: JSON string of filter conditions + schema: + type: string + format: jsonString + example: '{"name":"my-table"}' + - name: sortBy + in: query + description: 'Sort format: field:asc or field:desc' + schema: + type: string + example: 'name:asc' + responses: + '200': + description: Successfully retrieved data tables + content: + application/json: + schema: + $ref: '../schemas/dataTableList.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + security: + - ApiKeyAuth: [] + +post: + x-eov-operation-id: createDataTable + x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler + tags: + - DataTable + summary: Create a new data table + description: Create a new data table in your workspace. + operationId: create-data-table + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/createDataTableRequest.yml' + example: + name: 'customers' + columns: + - name: 'email' + type: 'string' + - name: 'status' + type: 'string' + - name: 'age' + type: 'number' + responses: + '201': + description: Data table created successfully + content: + application/json: + schema: + $ref: '../schemas/dataTable.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '409': + $ref: '../../../../shared/spec/responses/conflict.yml' + security: + - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml new file mode 100644 index 00000000000..3d2f9bdbc87 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml @@ -0,0 +1,27 @@ +type: object +properties: + name: + type: string + description: Name of the data table + minLength: 1 + maxLength: 128 + columns: + type: array + description: Column definitions for the table + items: + type: object + properties: + name: + type: string + description: Column name + minLength: 1 + type: + type: string + enum: [string, number, boolean, date, json] + description: Column data type + required: + - name + - type +required: + - name + - columns diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTable.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTable.yml new file mode 100644 index 00000000000..4feb25a8c44 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTable.yml @@ -0,0 +1,45 @@ +type: object +properties: + id: + type: string + description: Unique identifier for the data table + name: + type: string + description: Name of the data table + columns: + type: array + description: Column definitions + items: + type: object + properties: + id: + type: string + description: Column ID + name: + type: string + description: Column name + type: + type: string + enum: [string, number, boolean, date] + description: Column data type + index: + type: integer + description: Column position + projectId: + type: string + description: ID of the project this table belongs to + createdAt: + type: string + format: date-time + description: Timestamp when the table was created + updatedAt: + type: string + format: date-time + description: Timestamp when the table was last updated +required: + - id + - name + - columns + - projectId + - createdAt + - updatedAt diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableList.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableList.yml new file mode 100644 index 00000000000..fe95ade560a --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './dataTable.yml' + nextCursor: + type: string + description: Paginate through data tables by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRow.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRow.yml new file mode 100644 index 00000000000..a226281ce4c --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRow.yml @@ -0,0 +1,15 @@ +type: object +properties: + id: + type: integer + description: The row ID (auto-generated) + createdAt: + type: string + format: date-time + description: The date and time the row was created + updatedAt: + type: string + format: date-time + description: The date and time the row was last updated +additionalProperties: true +description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRowList.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRowList.yml new file mode 100644 index 00000000000..9e9ab42fbbd --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRowList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './dataTableRow.yml' + nextCursor: + type: string + description: Paginate through rows by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/insertRowsRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/insertRowsRequest.yml new file mode 100644 index 00000000000..a34f1b528b6 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/insertRowsRequest.yml @@ -0,0 +1,19 @@ +type: object +properties: + data: + type: array + items: + type: object + additionalProperties: true + description: Array of rows to insert. Each row is an object with column names as keys. + minItems: 1 + returnType: + type: string + enum: [count, id, all] + default: count + description: | + - count: Return only the number of rows inserted + - id: Return an array of inserted row IDs + - all: Return the full row data for all inserted rows +required: + - data diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/parameters/dataTableId.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/parameters/dataTableId.yml new file mode 100644 index 00000000000..5f1027b24a7 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/parameters/dataTableId.yml @@ -0,0 +1,7 @@ +name: dataTableId +in: path +description: The ID of the data table +required: true +schema: + type: string + format: nanoid diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateDataTableRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateDataTableRequest.yml new file mode 100644 index 00000000000..d4a5e0fc159 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateDataTableRequest.yml @@ -0,0 +1,9 @@ +type: object +properties: + name: + type: string + description: New name for the data table + minLength: 1 + maxLength: 128 +required: + - name diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateRowsRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateRowsRequest.yml new file mode 100644 index 00000000000..2f9c674be63 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateRowsRequest.yml @@ -0,0 +1,43 @@ +type: object +properties: + filter: + type: object + properties: + type: + type: string + enum: [and, or] + default: and + filters: + type: array + minItems: 1 + items: + type: object + properties: + columnName: + type: string + condition: + type: string + enum: [eq, neq, like, ilike, gt, gte, lt, lte] + value: {} + required: + - columnName + - condition + - value + required: + - filters + description: Filter conditions to match rows for update + data: + type: object + additionalProperties: true + description: Column values to update + returnData: + type: boolean + default: false + description: If true, return the updated rows; if false, return true on success + dryRun: + type: boolean + default: false + description: If true, preview changes without persisting them +required: + - filter + - data diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/upsertRowRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/upsertRowRequest.yml new file mode 100644 index 00000000000..7fcbcc2f272 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/upsertRowRequest.yml @@ -0,0 +1,43 @@ +type: object +properties: + filter: + type: object + properties: + type: + type: string + enum: [and, or] + default: and + filters: + type: array + minItems: 1 + items: + type: object + properties: + columnName: + type: string + condition: + type: string + enum: [eq, neq, like, ilike, gt, gte, lt, lte] + value: {} + required: + - columnName + - condition + - value + required: + - filters + description: Filter conditions to match existing row. If no row matches, a new row is inserted. + data: + type: object + additionalProperties: true + description: Column values for the row + returnData: + type: boolean + default: false + description: If true, return the upserted row; if false, return true on success + dryRun: + type: boolean + default: false + description: If true, preview changes without persisting them +required: + - filter + - data diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index ffde08918cf..8defc3fa8fd 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -32,6 +32,8 @@ tags: description: Operations about source control - name: Variables description: Operations about variables + - name: DataTable + description: Operations about data tables and their rows - name: Projects description: Operations about projects @@ -82,6 +84,18 @@ paths: $ref: './handlers/variables/spec/paths/variables.yml' /variables/{id}: $ref: './handlers/variables/spec/paths/variables.id.yml' + /data-tables: + $ref: './handlers/data-tables/spec/paths/data-tables.yml' + /data-tables/{dataTableId}: + $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.yml' + /data-tables/{dataTableId}/rows: + $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.yml' + /data-tables/{dataTableId}/rows/update: + $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml' + /data-tables/{dataTableId}/rows/upsert: + $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.upsert.yml' + /data-tables/{dataTableId}/rows/delete: + $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml' /projects: $ref: './handlers/projects/spec/paths/projects.yml' /projects/{projectId}: diff --git a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts index 1d2a13c94b7..346c3ae6ce1 100644 --- a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts @@ -17,7 +17,7 @@ import { decodeCursor } from '../services/pagination.service'; const UNLIMITED_USERS_QUOTA = -1; -export type ProjectScopeResource = 'workflow' | 'credential'; +export type ProjectScopeResource = 'workflow' | 'credential' | 'dataTable'; const buildScopeMiddleware = ( scopes: Scope[], @@ -25,17 +25,19 @@ const buildScopeMiddleware = ( { globalOnly } = { globalOnly: false }, ) => { return async ( - req: AuthenticatedRequest<{ id?: string }>, + req: AuthenticatedRequest<{ id?: string; dataTableId?: string }>, res: express.Response, next: express.NextFunction, ): Promise => { - const params: { credentialId?: string; workflowId?: string } = {}; + const params: { credentialId?: string; workflowId?: string; dataTableId?: string } = {}; if (req.params.id) { if (resource === 'workflow') { params.workflowId = req.params.id; } else if (resource === 'credential') { params.credentialId = req.params.id; } + } else if (req.params.dataTableId && resource === 'dataTable') { + params.dataTableId = req.params.dataTableId; } try { diff --git a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml index c8c6aba3423..e715aedc302 100644 --- a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml @@ -16,3 +16,5 @@ IncludeRole: $ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml' VariableId: $ref: '../../../handlers/variables/spec/schemas/parameters/variableId.yml' +dataTableId: + $ref: '../../../handlers/data-tables/spec/schemas/parameters/dataTableId.yml' diff --git a/packages/cli/test/integration/public-api/data-tables.test.ts b/packages/cli/test/integration/public-api/data-tables.test.ts new file mode 100644 index 00000000000..779e644a082 --- /dev/null +++ b/packages/cli/test/integration/public-api/data-tables.test.ts @@ -0,0 +1,1413 @@ +import { testDb } from '@n8n/backend-test-utils'; +import type { Project, User } from '@n8n/db'; +import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import type { DataTable } from '@/modules/data-table/data-table.entity'; + +import { createDataTable } from '../shared/db/data-tables'; +import { createOwnerWithApiKey, createMemberWithApiKey } from '../shared/db/users'; +import type { SuperAgentTest } from '../shared/types'; +import * as utils from '../shared/utils/'; + +let owner: User; +let member: User; +let ownerPersonalProject: Project; +let memberPersonalProject: Project; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; + +const testServer = utils.setupTestServer({ + endpointGroups: ['publicApi'], + modules: ['data-table'], +}); + +beforeAll(async () => { + owner = await createOwnerWithApiKey(); + member = await createMemberWithApiKey(); + + const projectRepository = Container.get(ProjectRepository); + ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); +}); + +beforeEach(async () => { + // Note: DataTable entities will be cascade deleted when projects are truncated + await testDb.truncate(['ProjectRelation', 'Project']); + + // Recreate personal projects + const projectRepository = Container.get(ProjectRepository); + const projectRelationRepository = Container.get(ProjectRelationRepository); + + const createPersonalProject = async (user: User) => { + const project = await projectRepository.save( + projectRepository.create({ + type: 'personal', + name: user.createPersonalProjectName(), + creatorId: user.id, + }), + ); + + await projectRelationRepository.save( + projectRelationRepository.create({ + projectId: project.id, + userId: user.id, + role: { slug: 'project:personalOwner' }, + }), + ); + + return project; + }; + + ownerPersonalProject = await createPersonalProject(owner); + memberPersonalProject = await createPersonalProject(member); + + authOwnerAgent = testServer.publicApiAgentFor(owner); + authMemberAgent = testServer.publicApiAgentFor(member); +}); + +const testWithAPIKey = + (method: 'get' | 'post' | 'put' | 'patch' | 'delete', url: string, apiKey: string | null) => + async () => { + void authOwnerAgent.set({ 'X-N8N-API-KEY': apiKey }); + const response = await authOwnerAgent[method](url); + expect(response.statusCode).toBe(401); + }; + +describe('GET /data-tables', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/data-tables', null)); + + test('should fail due to invalid API Key', testWithAPIKey('get', '/data-tables', 'abcXYZ')); + + test('should list data tables', async () => { + await createDataTable(ownerPersonalProject, { + name: 'table1', + columns: [{ name: 'name', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'table2', + columns: [{ name: 'age', type: 'number' }], + }); + + const response = await authOwnerAgent.get('/data-tables'); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('nextCursor'); + expect(response.body.data).toHaveLength(2); + expect(response.body.nextCursor).toBeNull(); + expect(response.body.data[0]).toHaveProperty('id'); + expect(response.body.data[0]).toHaveProperty('name'); + expect(response.body.data[0]).toHaveProperty('columns'); + }); + + test('should paginate data tables', async () => { + await createDataTable(ownerPersonalProject, { + name: 'table1', + columns: [{ name: 'col1', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'table2', + columns: [{ name: 'col2', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'table3', + columns: [{ name: 'col3', type: 'string' }], + }); + + // First page + const response1 = await authOwnerAgent.get('/data-tables').query({ limit: 2 }); + + expect(response1.statusCode).toBe(200); + expect(response1.body.data).toHaveLength(2); + expect(response1.body.nextCursor).toBeTruthy(); + + // Second page using cursor + const response2 = await authOwnerAgent + .get('/data-tables') + .query({ cursor: response1.body.nextCursor }); + + expect(response2.statusCode).toBe(200); + expect(response2.body.data).toHaveLength(1); + expect(response2.body.nextCursor).toBeNull(); + }); + + test('should sort data tables by name ascending', async () => { + await createDataTable(ownerPersonalProject, { + name: 'zebra', + columns: [{ name: 'col1', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'apple', + columns: [{ name: 'col2', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'mango', + columns: [{ name: 'col3', type: 'string' }], + }); + + const response = await authOwnerAgent.get('/data-tables').query({ sortBy: 'name:asc' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(3); + expect(response.body.data[0].name).toBe('apple'); + expect(response.body.data[1].name).toBe('mango'); + expect(response.body.data[2].name).toBe('zebra'); + }); + + test('should sort data tables by createdAt descending', async () => { + await createDataTable(ownerPersonalProject, { + name: 'first', + columns: [{ name: 'col1', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'second', + columns: [{ name: 'col2', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'third', + columns: [{ name: 'col3', type: 'string' }], + }); + + const response = await authOwnerAgent.get('/data-tables').query({ sortBy: 'createdAt:desc' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(3); + const createdAtTimes = response.body.data.map((table: any) => + new Date(table.createdAt).getTime(), + ); + expect(createdAtTimes).toEqual([...createdAtTimes].sort((a, b) => b - a)); + }); + + test('should reject invalid sortBy option (sizeBytes)', async () => { + await createDataTable(ownerPersonalProject, { + name: 'table1', + columns: [{ name: 'col1', type: 'string' }], + }); + + const response = await authOwnerAgent.get('/data-tables').query({ sortBy: 'sizeBytes:asc' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('sortBy must be one of'); + }); + + test('should use default limit of 100', async () => { + // Create more than 100 tables to test default + for (let i = 1; i <= 101; i++) { + await createDataTable(ownerPersonalProject, { + name: `table${i}`, + columns: [{ name: 'col', type: 'string' }], + }); + } + + const response = await authOwnerAgent.get('/data-tables'); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(100); + expect(response.body.nextCursor).toBeTruthy(); + }); +}); + +describe('POST /data-tables', () => { + test('should fail due to missing API Key', testWithAPIKey('post', '/data-tables', null)); + + test('should fail due to invalid API Key', testWithAPIKey('post', '/data-tables', 'abcXYZ')); + + test('should create a data table', async () => { + const response = await authOwnerAgent.post('/data-tables').send({ + name: 'my-table', + columns: [ + { name: 'email', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + expect(response.statusCode).toBe(201); + expect(response.body).toHaveProperty('id'); + expect(response.body).toHaveProperty('name', 'my-table'); + expect(response.body).toHaveProperty('columns'); + expect(response.body.columns).toHaveLength(2); + expect(response.body).toHaveProperty('projectId', ownerPersonalProject.id); + }); + + test('should fail with duplicate name', async () => { + await createDataTable(ownerPersonalProject, { + name: 'existing-table', + columns: [{ name: 'col1', type: 'string' }], + }); + + const response = await authOwnerAgent.post('/data-tables').send({ + name: 'existing-table', + columns: [{ name: 'col2', type: 'string' }], + }); + + expect(response.statusCode).toBe(409); + expect(response.body).toHaveProperty('message'); + }); + + test('should fail with invalid data', async () => { + const response = await authOwnerAgent.post('/data-tables').send({ + name: '', + columns: [], + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); + + test('should reject unsupported column type (json)', async () => { + const response = await authOwnerAgent.post('/data-tables').send({ + name: 'test-table', + columns: [{ name: 'data', type: 'json' }], + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + }); +}); + +describe('GET /data-tables/:dataTableId', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/data-tables/123', null)); + + test('should fail due to invalid API Key', testWithAPIKey('get', '/data-tables/123', 'abcXYZ')); + + test('should return 404 for non-existing data table', async () => { + const nonExistentId = 'abcd1234efgh5678'; + const response = await authOwnerAgent.get(`/data-tables/${nonExistentId}`); + + expect(response.statusCode).toBe(404); + expect(response.body).toHaveProperty( + 'message', + `Could not find the data table: '${nonExistentId}'`, + ); + }); + + test('should get a data table', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'test-table', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + const response = await authOwnerAgent.get(`/data-tables/${dataTable.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('id', dataTable.id); + expect(response.body).toHaveProperty('name', 'test-table'); + expect(response.body).toHaveProperty('columns'); + expect(response.body.columns).toHaveLength(2); + }); + + test('should return 403 when user does not have access to the data table', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'owner-table', + columns: [{ name: 'name', type: 'string' }], + }); + + const response = await authMemberAgent.get(`/data-tables/${dataTable.id}`); + + expect(response.statusCode).toBe(403); + expect(response.body).toHaveProperty('message'); + }); + + test('should allow access to own data table', async () => { + const dataTable = await createDataTable(memberPersonalProject, { + name: 'member-table', + columns: [{ name: 'name', type: 'string' }], + }); + + const response = await authMemberAgent.get(`/data-tables/${dataTable.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('id', dataTable.id); + expect(response.body).toHaveProperty('name', 'member-table'); + }); +}); + +describe('PATCH /data-tables/:dataTableId', () => { + test('should fail due to missing API Key', testWithAPIKey('patch', '/data-tables/123', null)); + + test('should fail due to invalid API Key', testWithAPIKey('patch', '/data-tables/123', 'abcXYZ')); + + test('should return 404 for non-existing data table', async () => { + const nonExistentId = 'abcd1234efgh5678'; + const response = await authOwnerAgent.patch(`/data-tables/${nonExistentId}`).send({ + name: 'new-name', + }); + + expect(response.statusCode).toBe(404); + expect(response.body).toHaveProperty( + 'message', + `Could not find the data table: '${nonExistentId}'`, + ); + }); + + test('should update a data table name', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'old-name', + columns: [{ name: 'col1', type: 'string' }], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}`).send({ + name: 'new-name', + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('id', dataTable.id); + expect(response.body).toHaveProperty('name', 'new-name'); + }); + + test('should fail with duplicate name', async () => { + const dataTable1 = await createDataTable(ownerPersonalProject, { + name: 'table1', + columns: [{ name: 'col1', type: 'string' }], + }); + await createDataTable(ownerPersonalProject, { + name: 'table2', + columns: [{ name: 'col2', type: 'string' }], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable1.id}`).send({ + name: 'table2', + }); + + expect(response.statusCode).toBe(409); + expect(response.body).toHaveProperty('message'); + }); + + test('should return 403 when user does not have access to update the data table', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'owner-table', + columns: [{ name: 'col1', type: 'string' }], + }); + + const response = await authMemberAgent.patch(`/data-tables/${dataTable.id}`).send({ + name: 'hacked-name', + }); + + expect(response.statusCode).toBe(403); + expect(response.body).toHaveProperty('message'); + }); +}); + +describe('DELETE /data-tables/:dataTableId', () => { + test('should fail due to missing API Key', testWithAPIKey('delete', '/data-tables/123', null)); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('delete', '/data-tables/123', 'abcXYZ'), + ); + + test('should return 404 for non-existing data table', async () => { + const nonExistentId = 'abcd1234efgh5678'; + const response = await authOwnerAgent.delete(`/data-tables/${nonExistentId}`); + + expect(response.statusCode).toBe(404); + expect(response.body).toHaveProperty( + 'message', + `Could not find the data table: '${nonExistentId}'`, + ); + }); + + test('should delete a data table', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'table-to-delete', + columns: [{ name: 'col1', type: 'string' }], + }); + + const response = await authOwnerAgent.delete(`/data-tables/${dataTable.id}`); + + expect(response.statusCode).toBe(204); + expect(response.body).toEqual({}); + + // Verify it's deleted + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}`); + expect(getResponse.statusCode).toBe(404); + }); + + test('should return 403 when user does not have access to delete the data table', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + name: 'owner-table', + columns: [{ name: 'col1', type: 'string' }], + }); + + const response = await authMemberAgent.delete(`/data-tables/${dataTable.id}`); + + expect(response.statusCode).toBe(403); + expect(response.body).toHaveProperty('message'); + + // Verify it's not actually deleted + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}`); + expect(getResponse.statusCode).toBe(200); + }); +}); + +describe('GET /data-tables/:dataTableId/rows', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/data-tables/123/rows', null)); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('get', '/data-tables/123/rows', 'abcXYZ'), + ); + + test('should return 404 for non-existing data table', async () => { + const nonExistentId = 'abcd1234efgh5678'; // Valid nanoid format but doesn't exist + const response = await authOwnerAgent.get(`/data-tables/${nonExistentId}/rows`); + + expect(response.statusCode).toBe(404); + expect(response.body).toHaveProperty( + 'message', + `Could not find the data table: '${nonExistentId}'`, + ); + }); + + test('should retrieve rows from own data table', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + data: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + }); + + const response = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.nextCursor).toBeNull(); + + const row = response.body.data[0]; + expect(row).toHaveProperty('id'); + expect(row).toHaveProperty('name'); + expect(row).toHaveProperty('age'); + expect(row).toHaveProperty('createdAt'); + expect(row).toHaveProperty('updatedAt'); + }); + + test('should sort rows by column ascending', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + data: [ + { name: 'Charlie', age: 35 }, + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ sortBy: 'name:asc' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(3); + expect(response.body.data[0].name).toBe('Alice'); + expect(response.body.data[1].name).toBe('Bob'); + expect(response.body.data[2].name).toBe('Charlie'); + }); + + test('should sort rows by column descending', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + data: [ + { name: 'Charlie', age: 35 }, + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ sortBy: 'age:desc' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(3); + expect(response.body.data[0].age).toBe(35); + expect(response.body.data[1].age).toBe(30); + expect(response.body.data[2].age).toBe(25); + }); + + test('should reject invalid sort format', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'name', type: 'string' }], + data: [{ name: 'Alice' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ sortBy: 'invalid_format' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Invalid sort format, expected :'); + }); + + test('should reject invalid sort direction', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'name', type: 'string' }], + data: [{ name: 'Alice' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ sortBy: 'name:invalid' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Invalid sort direction'); + }); + + test('should sort by system column (id)', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'name', type: 'string' }], + data: [{ name: 'Charlie' }, { name: 'Alice' }, { name: 'Bob' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ sortBy: 'id:asc' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(3); + // IDs are auto-incrementing, so ascending order means first inserted comes first + expect(response.body.data[0].name).toBe('Charlie'); + expect(response.body.data[1].name).toBe('Alice'); + expect(response.body.data[2].name).toBe('Bob'); + }); + + test('should sort by system column (createdAt)', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'name', type: 'string' }], + data: [{ name: 'Charlie' }, { name: 'Alice' }, { name: 'Bob' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ sortBy: 'createdAt:desc' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(3); + expect(response.body.data.every((row: any) => row.createdAt)).toBe(true); + + const createdAtTimes = response.body.data.map((row: any) => new Date(row.createdAt).getTime()); + expect(createdAtTimes).toEqual([...createdAtTimes].sort((a, b) => b - a)); + }); + + test('should reject column names with invalid characters', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'name', type: 'string' }], + data: [{ name: 'Alice' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ sortBy: 'user-name:asc' }); // hyphen not allowed + + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('alphabetical characters'); + }); + + test('should paginate with cursor and limit', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'position', type: 'number' }, + ], + data: [ + { name: 'Alice', position: 1 }, + { name: 'Bob', position: 2 }, + { name: 'Charlie', position: 3 }, + { name: 'David', position: 4 }, + { name: 'Eve', position: 5 }, + ], + }); + + // Test first page with limit + const response1 = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ limit: 2, sortBy: 'position:asc' }); + + expect(response1.statusCode).toBe(200); + expect(response1.body.data).toHaveLength(2); + expect(response1.body.data[0].name).toBe('Alice'); + expect(response1.body.data[1].name).toBe('Bob'); + expect(response1.body.nextCursor).toBeTruthy(); + + // Test second page using cursor + const response2 = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ cursor: response1.body.nextCursor, sortBy: 'position:asc' }); + + expect(response2.statusCode).toBe(200); + expect(response2.body.data).toHaveLength(2); + expect(response2.body.data[0].name).toBe('Charlie'); + expect(response2.body.data[1].name).toBe('David'); + expect(response2.body.nextCursor).toBeTruthy(); + + // Test third page using cursor + const response3 = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ cursor: response2.body.nextCursor, sortBy: 'position:asc' }); + + expect(response3.statusCode).toBe(200); + expect(response3.body.data).toHaveLength(1); + expect(response3.body.data[0].name).toBe('Eve'); + expect(response3.body.nextCursor).toBeNull(); + }); + + test('should search across columns', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'email', type: 'string' }, + ], + data: [ + { name: 'Alice', email: 'alice@example.com' }, + { name: 'Bob', email: 'bob@test.com' }, + { name: 'Charlie', email: 'charlie@example.com' }, + ], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ search: 'example' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.data.map((r: any) => r.name).sort()).toEqual(['Alice', 'Charlie']); + }); +}); + +describe('POST /data-tables/:dataTableId/rows', () => { + test('should fail due to missing API Key', testWithAPIKey('post', '/data-tables/123/rows', null)); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('post', '/data-tables/123/rows', 'abcXYZ'), + ); + + test('should insert rows with returnType count', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'email', type: 'string' }, + ], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows`).send({ + data: [ + { name: 'Alice', email: 'alice@example.com' }, + { name: 'Bob', email: 'bob@example.com' }, + ], + returnType: 'count', + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ insertedRows: 2, success: true }); + }); + + test('should insert rows with returnType id', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'name', type: 'string' }], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows`).send({ + data: [{ name: 'Alice' }, { name: 'Bob' }], + returnType: 'id', + }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + // returnType 'id' returns objects with id property + expect(response.body[0]).toHaveProperty('id'); + expect(typeof response.body[0].id).toBe('number'); + expect(response.body[1]).toHaveProperty('id'); + expect(typeof response.body[1].id).toBe('number'); + }); + + test('should insert rows with returnType all', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows`).send({ + data: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + returnType: 'all', + }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('id'); + expect(response.body[0]).toHaveProperty('name', 'Alice'); + expect(response.body[0]).toHaveProperty('age', 30); + expect(response.body[1]).toHaveProperty('name', 'Bob'); + expect(response.body[1]).toHaveProperty('age', 25); + }); + + test('should use default returnType when not provided', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'name', type: 'string' }], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows`).send({ + data: [{ name: 'Alice' }], + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ insertedRows: 1, success: true }); + }); +}); + +describe('PATCH /data-tables/:dataTableId/rows/update', () => { + test( + 'should fail due to missing API Key', + testWithAPIKey('patch', '/data-tables/123/rows/update', null), + ); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('patch', '/data-tables/123/rows/update', 'abcXYZ'), + ); + + test('should update rows with returnData false', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'status', type: 'string' }, + { name: 'count', type: 'number' }, + ], + data: [ + { status: 'pending', count: 1 }, + { status: 'pending', count: 2 }, + ], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'pending' }], + }, + data: { status: 'completed' }, + returnData: false, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(true); + }); + + test('should update rows with returnData true', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'status', type: 'string' }, + { name: 'value', type: 'number' }, + ], + data: [ + { status: 'pending', value: 10 }, + { status: 'pending', value: 20 }, + { status: 'active', value: 30 }, + ], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'pending' }], + }, + data: { status: 'completed' }, + returnData: true, + }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('status', 'completed'); + expect(response.body[1]).toHaveProperty('status', 'completed'); + }); + + test('should preview update with dryRun true', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'status', type: 'string' }, + { name: 'count', type: 'number' }, + ], + data: [ + { status: 'pending', count: 1 }, + { status: 'pending', count: 2 }, + ], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'pending' }], + }, + data: { status: 'completed' }, + dryRun: true, + returnData: true, + }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + // dryRun returns both before and after states for each row + expect(response.body).toHaveLength(4); + expect(response.body.filter((r: any) => r.dryRunState === 'before')).toHaveLength(2); + expect(response.body.filter((r: any) => r.dryRunState === 'after')).toHaveLength(2); + + // Verify data was not actually updated + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`); + expect(getResponse.body.data.every((row: any) => row.status === 'pending')).toBe(true); + }); +}); + +describe('POST /data-tables/:dataTableId/rows/upsert', () => { + test( + 'should fail due to missing API Key', + testWithAPIKey('post', '/data-tables/123/rows/upsert', null), + ); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('post', '/data-tables/123/rows/upsert', 'abcXYZ'), + ); + + test('should upsert row with returnData false', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + ], + data: [{ email: 'test@example.com', name: 'Test User' }], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { email: 'test@example.com', name: 'Updated User' }, + returnData: false, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(true); + }); + + test('should upsert row with returnData true', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'email', type: 'string' }, + { name: 'status', type: 'string' }, + ], + data: [{ email: 'existing@example.com', status: 'old' }], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'existing@example.com' }], + }, + data: { email: 'existing@example.com', status: 'updated' }, + returnData: true, + }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toHaveProperty('email', 'existing@example.com'); + expect(response.body[0]).toHaveProperty('status', 'updated'); + }); + + test('should preview upsert with dryRun true', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'email', type: 'string' }, + { name: 'count', type: 'number' }, + ], + data: [{ email: 'test@example.com', count: 5 }], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { email: 'test@example.com', count: 10 }, + dryRun: true, + returnData: true, + }); + + expect(response.statusCode).toBe(200); + // dryRun returns both before and after states + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('dryRunState', 'before'); + expect(response.body[0]).toHaveProperty('count', 5); + expect(response.body[1]).toHaveProperty('dryRunState', 'after'); + expect(response.body[1]).toHaveProperty('count', 10); + + // Verify data was not actually updated + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`); + expect(getResponse.body.data[0].count).toBe(5); + }); +}); + +describe('DELETE /data-tables/:dataTableId/rows/delete', () => { + test( + 'should fail due to missing API Key', + testWithAPIKey('delete', '/data-tables/123/rows/delete', null), + ); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('delete', '/data-tables/123/rows/delete', 'abcXYZ'), + ); + + test('should delete rows with returnData false', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'old' }, { status: 'active' }], + }); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'old' }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, returnData: 'false' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(true); + }); + + test('should delete rows with returnData true', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'status', type: 'string' }, + { name: 'value', type: 'number' }, + ], + data: [ + { status: 'archived', value: 1 }, + { status: 'archived', value: 2 }, + { status: 'active', value: 3 }, + ], + }); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'archived' }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, returnData: 'true' }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('status', 'archived'); + expect(response.body[1]).toHaveProperty('status', 'archived'); + }); + + test('should preview delete with dryRun true', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'status', type: 'string' }, + { name: 'id_num', type: 'number' }, + ], + data: [ + { status: 'temp', id_num: 1 }, + { status: 'temp', id_num: 2 }, + { status: 'permanent', id_num: 3 }, + ], + }); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'temp' }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, dryRun: 'true', returnData: 'true' }); + + expect(response.statusCode).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + // dryRun returns both before and after states for each row + expect(response.body).toHaveLength(4); + expect(response.body.filter((r: any) => r.dryRunState === 'before')).toHaveLength(2); + expect(response.body.filter((r: any) => r.dryRunState === 'after')).toHaveLength(2); + + // Verify data was not actually deleted + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`); + expect(getResponse.body.data.length).toBe(3); + }); + + test('should return 400 when filter is missing', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'data', type: 'string' }], + }); + + const response = await authOwnerAgent.delete(`/data-tables/${dataTable.id}/rows/delete`); + + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty( + 'message', + "request/query must have required property 'filter'", + ); + }); +}); + +describe('Filter Parameter Validation', () => { + let dataTable: DataTable; + + beforeEach(async () => { + dataTable = await createDataTable(ownerPersonalProject, { + columns: [ + { name: 'name', type: 'string' }, + { name: 'status', type: 'string' }, + { name: 'score', type: 'number' }, + { name: 'active', type: 'boolean' }, + ], + data: [ + { name: 'Alice', status: 'active', score: 95, active: true }, + { name: 'Bob', status: 'inactive', score: 75, active: false }, + { name: 'Charlie', status: 'active', score: 85, active: true }, + { name: 'Diana', status: 'pending', score: 90, active: false }, + { name: 'Eve', status: 'active', score: 80, active: true }, + ], + }); + }); + + describe('GET with filter conditions', () => { + test('should filter with eq (equals) condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'active' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + expect(response.body.data.every((row: any) => row.status === 'active')).toBe(true); + }); + + test('should filter with neq (not equals) condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'neq', value: 'active' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.data.every((row: any) => row.status !== 'active')).toBe(true); + }); + + test('should filter with gt (greater than) condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'score', condition: 'gt', value: 85 }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.data.every((row: any) => row.score > 85)).toBe(true); + }); + + test('should filter with gte (greater than or equal) condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'score', condition: 'gte', value: 85 }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + expect(response.body.data.every((row: any) => row.score >= 85)).toBe(true); + }); + + test('should filter with lt (less than) condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'score', condition: 'lt', value: 85 }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.data.every((row: any) => row.score < 85)).toBe(true); + }); + + test('should filter with lte (less than or equal) condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'score', condition: 'lte', value: 85 }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + expect(response.body.data.every((row: any) => row.score <= 85)).toBe(true); + }); + + test('should filter with like condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'name', condition: 'like', value: '%li%' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); // Alice and Charlie + }); + + test('should filter with ilike (case-insensitive like) condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'name', condition: 'ilike', value: '%ALI%' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + // Note: SQLite LIKE is case-insensitive by default, so ilike behaves the same + expect(response.body.data.length).toBe(1); // Only Alice contains 'ALI' + }); + }); + + describe('Filter with AND/OR types', () => { + test('should filter with type "and" - multiple conditions must all match', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [ + { columnName: 'status', condition: 'eq', value: 'active' }, + { columnName: 'score', condition: 'gte', value: 85 }, + ], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); // Alice (95) and Charlie (85) + expect( + response.body.data.every((row: any) => row.status === 'active' && row.score >= 85), + ).toBe(true); + }); + + test('should filter with type "or" - any condition can match', async () => { + const filter = JSON.stringify({ + type: 'or', + filters: [ + { columnName: 'status', condition: 'eq', value: 'pending' }, + { columnName: 'score', condition: 'gte', value: 95 }, + ], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); // Diana (pending) and Alice (score 95) + }); + }); + + describe('Filter with boolean values', () => { + test('should filter boolean column with true value', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'active', condition: 'eq', value: true }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + expect(response.body.data.every((row: any) => row.active === true)).toBe(true); + }); + + test('should filter boolean column with false value', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'active', condition: 'eq', value: false }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.data.every((row: any) => row.active === false)).toBe(true); + }); + }); + + describe('Filter with null values', () => { + beforeEach(async () => { + // Add a row with null value + await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows`).send({ + data: [{ name: 'Frank', status: null, score: 70, active: true }], + returnType: 'count', + }); + }); + + test('should filter for null values', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: null }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].status).toBeNull(); + }); + + test('should filter for non-null values', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'neq', value: null }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(5); + expect(response.body.data.every((row: any) => row.status !== null)).toBe(true); + }); + }); + + describe('Invalid filter formats', () => { + test('should return 400 for invalid JSON filter', async () => { + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter: '{invalid json}' }); + + expect(response.statusCode).toBe(400); + }); + + test('should return 400 for filter with invalid condition', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'invalid', value: 'active' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(400); + }); + + test('should return 400 for filter with missing columnName', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ condition: 'eq', value: 'active' }], + }); + + const response = await authOwnerAgent + .get(`/data-tables/${dataTable.id}/rows`) + .query({ filter }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('Filter in UPDATE operations', () => { + test('should update only rows matching filter', async () => { + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { + type: 'and', + filters: [ + { columnName: 'status', condition: 'eq', value: 'active' }, + { columnName: 'score', condition: 'lt', value: 90 }, + ], + }, + data: { status: 'promoted' }, + returnData: false, + }); + + expect(response.statusCode).toBe(200); + + // Verify the update + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`).query({ + filter: JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'promoted' }], + }), + }); + + expect(getResponse.body.data.length).toBe(2); // Charlie (85) and Eve (80) + }); + }); + + describe('Filter in DELETE operations', () => { + test('should delete only rows matching filter', async () => { + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'score', condition: 'lt', value: 80 }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, returnData: 'false' }); + + expect(response.statusCode).toBe(200); + + // Verify remaining rows + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`); + + expect(getResponse.body.data.length).toBe(4); // Bob (75) should be deleted + }); + }); +}); diff --git a/packages/nodes-base/nodes/DataTable/actions/table/list.operation.ts b/packages/nodes-base/nodes/DataTable/actions/table/list.operation.ts index 355edcbee01..7e2ab5b2e52 100644 --- a/packages/nodes-base/nodes/DataTable/actions/table/list.operation.ts +++ b/packages/nodes-base/nodes/DataTable/actions/table/list.operation.ts @@ -4,7 +4,6 @@ import type { INodeExecutionData, INodeProperties, ListDataTableOptions, - ListDataTableOptionsSortByKey, } from 'n8n-workflow'; import { ROWS_LIMIT_DEFAULT } from '../../common/constants'; @@ -67,9 +66,8 @@ export const description: INodeProperties[] = [ options: [ { name: 'Created', value: 'createdAt' }, { name: 'Name', value: 'name' }, - { name: 'Size', value: 'sizeBytes' }, { name: 'Updated', value: 'updatedAt' }, - ] satisfies Array<{ name: string; value: ListDataTableOptionsSortByKey }>, + ], description: 'Field to sort by', }, { diff --git a/packages/workflow/src/data-table.types.ts b/packages/workflow/src/data-table.types.ts index 5278363229e..f70f7fcbbc6 100644 --- a/packages/workflow/src/data-table.types.ts +++ b/packages/workflow/src/data-table.types.ts @@ -45,7 +45,7 @@ export type CreateDataTableOptions = Pick & { export type UpdateDataTableOptions = { name: string }; -export type ListDataTableOptionsSortByKey = 'name' | 'createdAt' | 'updatedAt' | 'sizeBytes'; +export type ListDataTableOptionsSortByKey = 'name' | 'createdAt' | 'updatedAt'; export type ListDataTableOptions = { filter?: Record;