feat(core): Project based data table creation and transfer (#28323)

This commit is contained in:
Sandra Zollner 2026-04-14 14:38:44 +02:00 committed by GitHub
parent 59edd6ae54
commit 24015b3449
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 354 additions and 108 deletions

View file

@ -17,48 +17,48 @@ const VALID_SORT_OPTIONS = [
export type ListDataTableQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number];
const FILTER_OPTIONS = {
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
projectId: z.union([z.string(), z.array(z.string())]).optional(),
// todo: can probably include others here as well?
};
const filterSchema = z
.object({
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
projectId: z.union([z.string(), z.array(z.string())]).optional(),
// todo: can probably include others here as well?
})
.strict();
// Filter schema - only allow specific properties
const filterSchema = z.object(FILTER_OPTIONS).strict();
// ---------------------
// Parameter Validators
// ---------------------
// Public API restricts projectId to a single string
const publicApiFilterSchema = filterSchema.extend({ projectId: z.string().optional() }).strict();
// Filter parameter validation
const filterValidator = z
.string()
.optional()
.transform((val, ctx) => {
if (!val) return undefined;
try {
const parsed: unknown = jsonParse(val);
const makeFilterValidator = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
z
.string()
.optional()
.transform((val, ctx): z.infer<T> | undefined => {
if (!val) return undefined;
try {
return filterSchema.parse(parsed);
} catch (e) {
const result = schema.safeParse(jsonParse(val));
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
path: ['filter'],
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
});
});
const filterValidator = makeFilterValidator(filterSchema);
const publicApiFilterValidator = makeFilterValidator(publicApiFilterSchema);
// SortBy parameter validation
const sortByValidator = z
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
.optional();
@ -71,6 +71,6 @@ export class ListDataTableQueryDto extends Z.class({
export class PublicApiListDataTableQueryDto extends Z.class({
...publicApiPaginationSchema,
filter: filterValidator,
filter: publicApiFilterValidator,
sortBy: sortByValidator,
}) {}

View file

@ -0,0 +1,13 @@
import { z } from 'zod';
import { CreateDataTableColumnDto } from './create-data-table-column.dto';
import { dataTableNameSchema } from '../../schemas/data-table.schema';
import { Z } from '../../zod-class';
export class PublicApiCreateDataTableDto extends Z.class({
name: dataTableNameSchema,
columns: z.array(CreateDataTableColumnDto.schema),
fileId: z.string().optional(),
hasHeaders: z.boolean().optional(),
projectId: z.string().optional(),
}) {}

View file

@ -168,6 +168,7 @@ export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto'
export { CreateDataTableDto } from './data-table/create-data-table.dto';
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
export { PublicApiCreateDataTableDto } from './data-table/public-api-create-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';

View file

@ -45,11 +45,11 @@ export type DataTableColumn = z.infer<typeof dataTableColumnSchema>;
export type DataTableListFilter = {
id?: string | string[];
projectId?: string | string[];
name?: string;
name?: string | string[];
};
export type DataTableListOptions = Partial<ListDataTableQueryDto> & {
filter: { projectId: string };
filter: DataTableListFilter;
};
export type DataTableListSortBy = ListDataTableQueryDto['sortBy'];

View file

@ -5,11 +5,11 @@ import { ProjectRelationRepository, type User } from '@n8n/db';
import type { DataTableInfoById } from 'n8n-workflow';
import type { DataTableColumn } from '../data-table-column.entity';
import type { DataTable } from '../data-table.entity';
import { DataTableColumnRepository } from '../data-table-column.repository';
import { DataTableCsvImportService } from '../data-table-csv-import.service';
import { DataTableRowsRepository } from '../data-table-rows.repository';
import { DataTableSizeValidator } from '../data-table-size-validator.service';
import type { DataTable } from '../data-table.entity';
import { DataTableRepository } from '../data-table.repository';
import { DataTableService } from '../data-table.service';
import { DataTableColumnNotFoundError } from '../errors/data-table-column-not-found.error';

View file

@ -2,7 +2,7 @@ import type { AuthenticatedRequest, TagEntity, WorkflowEntity } from '@n8n/db';
import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow';
import type {
AddDataTableRowsDto,
CreateDataTableDto,
PublicApiCreateDataTableDto,
UpdateDataTableDto,
UpdateDataTableRowDto,
UpsertDataTableRowDto,
@ -257,7 +257,7 @@ export declare namespace DataTableRequest {
}
>;
type Create = AuthenticatedRequest<{}, {}, CreateDataTableDto, {}>;
type Create = AuthenticatedRequest<{}, {}, PublicApiCreateDataTableDto, {}>;
type Get = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>;

View file

@ -1,11 +1,12 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { ProjectRepository } from '@n8n/db';
import { ProjectRelationRepository, 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 { ProjectService } from '@/services/project.service.ee';
import type { DataTableRequest } from '@/public-api/types';
import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware';
@ -15,22 +16,29 @@ jest.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware);
jest.spyOn(middlewares, 'projectScope').mockReturnValue(mockMiddleware);
jest.spyOn(middlewares, 'validCursor').mockReturnValue(mockMiddleware);
const mainHandler = require('../data-tables.handler');
const handler = require('../data-tables.rows.handler');
describe('DataTable Handler', () => {
let mockDataTableService: jest.Mocked<DataTableService>;
let mockDataTableRepository: jest.Mocked<DataTableRepository>;
let mockProjectRepository: jest.Mocked<ProjectRepository>;
let mockProjectRelationRepository: jest.Mocked<ProjectRelationRepository>;
let mockProjectService: jest.Mocked<ProjectService>;
let mockResponse: Partial<Response>;
const projectId = 'test-project-id';
const dataTableId = 'test-data-table-id';
const userId = 'test-user-id';
const makeUser = (role = 'global:member') => ({ id: userId, role: { slug: role } });
beforeEach(() => {
mockDataTableService = mockInstance(DataTableService);
mockDataTableRepository = mockInstance(DataTableRepository);
mockProjectRepository = mockInstance(ProjectRepository);
mockProjectRelationRepository = mockInstance(ProjectRelationRepository);
mockProjectService = mockInstance(ProjectService);
jest.spyOn(Container, 'get').mockImplementation((serviceClass) => {
if (serviceClass === DataTableService) {
@ -42,6 +50,12 @@ describe('DataTable Handler', () => {
if (serviceClass === ProjectRepository) {
return mockProjectRepository as any;
}
if (serviceClass === ProjectRelationRepository) {
return mockProjectRelationRepository as any;
}
if (serviceClass === ProjectService) {
return mockProjectService as any;
}
return {} as any;
});
@ -54,14 +68,59 @@ describe('DataTable Handler', () => {
id: projectId,
} as any);
mockProjectRelationRepository.find.mockResolvedValue([]);
mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
};
jest.clearAllMocks();
});
describe('createDataTable', () => {
it('should create in personal project when no projectId provided', async () => {
const req = {
body: { name: 'test-table', columns: [{ name: 'col1', type: 'string' }] },
user: makeUser(),
} as unknown as DataTableRequest.Create;
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
id: projectId,
} as never);
mockDataTableService.createDataTable.mockResolvedValue({
id: dataTableId,
name: 'test-table',
columns: [],
project: { id: projectId },
} as never);
await mainHandler.createDataTable[1](req, mockResponse as Response);
expect(mockProjectRepository.getPersonalProjectForUserOrFail).toHaveBeenCalledWith(userId);
expect(mockDataTableService.createDataTable).toHaveBeenCalledWith(
projectId,
expect.not.objectContaining({ projectId: expect.anything() }),
);
expect(mockResponse.status).toHaveBeenCalledWith(201);
});
});
describe('listDataTables', () => {
it('should include personal and team projects for regular user', async () => {
const req = { query: {}, user: makeUser() } as unknown as DataTableRequest.List;
mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({
id: projectId,
} as never);
mockDataTableService.getManyAndCount.mockResolvedValue({ data: [], count: 0 } as never);
await mainHandler.listDataTables[2](req, mockResponse as Response);
const callArgs = mockDataTableService.getManyAndCount.mock.calls[0][0];
expect(callArgs.filter?.projectId).toContain(projectId);
});
});
describe('getDataTableRows', () => {
it('should retrieve rows successfully', async () => {
// Arrange

View file

@ -1,9 +1,8 @@
import {
PublicApiListDataTableQueryDto,
CreateDataTableDto,
PublicApiCreateDataTableDto,
UpdateDataTableDto,
} from '@n8n/api-types';
import { ProjectRepository, ProjectRelationRepository } from '@n8n/db';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { Container } from '@n8n/di';
import type express from 'express';
@ -19,6 +18,14 @@ 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';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import {
getProjectIdForDataTable,
getDataTableListFilter,
resolveProjectIdForCreate,
} from './data-tables.service';
import { ProjectService } from '@/services/project.service.ee';
const handleError = (error: unknown, res: express.Response): express.Response => {
if (error instanceof DataTableNotFoundError) {
@ -30,6 +37,9 @@ const handleError = (error: unknown, res: express.Response): express.Response =>
if (error instanceof DataTableValidationError) {
return res.status(400).json({ message: error.message });
}
if (error instanceof ForbiddenError) {
return res.status(error.httpStatusCode).json({ message: error.message });
}
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
}
@ -50,23 +60,6 @@ const stringifyQuery = (query: Record<string, unknown>): Record<string, string |
return result;
};
/**
* Gets the project ID for a data table.
* Called AFTER projectScope middleware has validated access.
*/
const getProjectIdForDataTable = async (dataTableId: string): Promise<string> => {
const dataTable = await Container.get(DataTableRepository).findOne({
where: { id: dataTableId },
relations: ['project'],
});
if (!dataTable) {
throw new DataTableNotFoundError(dataTableId);
}
return dataTable.project.id;
};
export = {
listDataTables: [
publicApiScope('dataTable:list'),
@ -83,35 +76,26 @@ export = {
const { offset, limit, filter, sortBy } = payload.data;
const providedFilter = filter ?? {};
const { projectId: _ignoredProjectId, ...restFilter } = providedFilter;
const { projectId: requestedProjectId, ...restFilter } = providedFilter;
const isGlobalOwnerOrAdmin = ['global:owner', 'global:admin'].includes(req.user.role.slug);
let finalFilter: any;
if (isGlobalOwnerOrAdmin) {
finalFilter = restFilter;
} else {
const personalProject = await Container.get(
ProjectRepository,
).getPersonalProjectForUserOrFail(req.user.id);
const projectRelations = await Container.get(ProjectRelationRepository).find({
where: { userId: req.user.id },
relations: ['project'],
});
const teamProjectIds = projectRelations
.filter((rel) => rel.project.type === 'team')
.map((rel) => rel.projectId);
const allAccessibleProjectIds = [personalProject.id, ...teamProjectIds];
finalFilter = {
...restFilter,
projectId: allAccessibleProjectIds,
};
if (requestedProjectId && !isGlobalOwnerOrAdmin) {
const projectWithScope = await Container.get(ProjectService).getProjectWithScope(
req.user,
requestedProjectId,
['dataTable:listProject'],
);
if (!projectWithScope) return res.json({ data: [], nextCursor: null });
}
const finalFilter = await getDataTableListFilter(
req.user.id,
isGlobalOwnerOrAdmin,
requestedProjectId,
restFilter,
);
const result = await Container.get(DataTableService).getManyAndCount({
skip: offset,
take: limit,
@ -138,22 +122,17 @@ export = {
createDataTable: [
publicApiScope('dataTable:create'),
async (req: DataTableRequest.Create, res: express.Response): Promise<express.Response> => {
const payload = PublicApiCreateDataTableDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body');
}
const { projectId: requestedProjectId, ...dto } = payload.data;
const projectId = await resolveProjectIdForCreate(req.user, requestedProjectId);
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 result = await Container.get(DataTableService).createDataTable(projectId, dto);
const { project: _project, ...dataTable } = result;

View file

@ -0,0 +1,82 @@
import { type DataTableListFilter } from '@n8n/api-types';
import { ProjectRepository, ProjectRelationRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { ProjectService } from '@/services/project.service.ee';
/**
* Gets the project ID for a data table.
* Called AFTER projectScope middleware has validated access.
*/
export async function getProjectIdForDataTable(dataTableId: string): Promise<string> {
const dataTable = await Container.get(DataTableRepository).findOne({
where: { id: dataTableId },
relations: ['project'],
});
if (!dataTable) {
throw new DataTableNotFoundError(dataTableId);
}
return dataTable.project.id;
}
export async function getDataTableListFilter(
userId: string,
isGlobalOwnerOrAdmin: boolean,
requestedProjectId: string | string[] | undefined,
restFilter: Omit<DataTableListFilter, 'projectId'>,
): Promise<DataTableListFilter> {
if (requestedProjectId) {
return { ...restFilter, projectId: requestedProjectId };
}
if (isGlobalOwnerOrAdmin) {
return restFilter;
}
const personalProject =
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
const projectRelations = await Container.get(ProjectRelationRepository).find({
where: { userId },
relations: ['project'],
});
const teamProjectIds = projectRelations
.filter((rel) => rel.project.type === 'team')
.map((rel) => rel.projectId);
return {
...restFilter,
projectId: [personalProject.id, ...teamProjectIds],
};
}
export async function resolveProjectIdForCreate(
user: User,
requestedProjectId?: string,
): Promise<string> {
if (!requestedProjectId) {
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(user.id);
return project.id;
}
const exists = await Container.get(ProjectRepository).findOneBy({ id: requestedProjectId });
if (!exists) {
throw new BadRequestError(`Project with ID "${requestedProjectId}" not found`);
}
const project = await Container.get(ProjectService).getProjectWithScope(
user,
requestedProjectId,
['dataTable:create'],
);
if (!project) throw new ForbiddenError();
return project.id;
}

View file

@ -42,7 +42,7 @@ post:
tags:
- DataTable
summary: Create a new data table
description: Create a new data table in your workspace.
description: Create a new data table in your personal project or a team project you have access to.
operationId: create-data-table
requestBody:
required: true
@ -50,15 +50,28 @@ post:
application/json:
schema:
$ref: '../schemas/createDataTableRequest.yml'
example:
name: 'customers'
columns:
- name: 'email'
type: 'string'
- name: 'status'
type: 'string'
- name: 'age'
type: 'number'
examples:
personalProject:
summary: Default (personal project)
value:
name: 'customers'
columns:
- name: 'email'
type: 'string'
- name: 'status'
type: 'string'
- name: 'age'
type: 'number'
scopedProject:
summary: Explicit project
value:
name: 'customers'
projectId: 'a1b2c3d4'
columns:
- name: 'email'
type: 'string'
- name: 'status'
type: 'string'
responses:
'201':
description: Data table created successfully
@ -70,6 +83,8 @@ post:
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'409':
$ref: '../../../../shared/spec/responses/conflict.yml'
security:

View file

@ -22,6 +22,10 @@ properties:
required:
- name
- type
projectId:
type: string
description: |
ID of the project to create the table in. When omitted, the table is created in the user's personal project.
required:
- name
- columns

View file

@ -591,7 +591,10 @@ export class ProjectService {
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = await this.roleService.rolesWithScope('project', scopes);
where.type = 'team';
// if we're not checking specific projects, restrict to team projects
if (!projectIds) {
where.type = 'team';
}
where.projectRelations = {
role: In(projectRoles),
userId: user.id,

View file

@ -1540,3 +1540,93 @@ describe('Filter Parameter Validation', () => {
});
});
});
describe('POST /data-tables with projectId', () => {
beforeEach(() => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
});
test('should create a table in the specified team project when user is a member', async () => {
const teamProject = await createTeamProject('Team Create');
await linkUserToProject(member, teamProject, 'project:editor');
const response = await authMemberAgent.post('/data-tables').send({
name: 'team-table',
columns: [{ name: 'col1', type: 'string' }],
projectId: teamProject.id,
});
expect(response.statusCode).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('name', 'team-table');
expect(response.body).toHaveProperty('projectId', teamProject.id);
expect(response.body).not.toHaveProperty('project');
});
test('should return 403 when user is not a member of the specified project', async () => {
const teamProject = await createTeamProject('Team No Access');
// member is NOT added to teamProject
const response = await authMemberAgent.post('/data-tables').send({
name: 'unauthorized-table',
columns: [{ name: 'col1', type: 'string' }],
projectId: teamProject.id,
});
expect(response.statusCode).toBe(403);
});
});
describe('GET /data-tables with projectId filter', () => {
beforeEach(() => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
});
test('should return only tables from the specified team project when user is a member', async () => {
const teamProject = await createTeamProject('Team Filter');
await linkUserToProject(member, teamProject, 'project:viewer');
await createDataTable(memberPersonalProject, {
name: 'personal-table',
columns: [{ name: 'col', type: 'string' }],
});
await createDataTable(teamProject, {
name: 'team-table',
columns: [{ name: 'col', type: 'string' }],
});
const filter = JSON.stringify({ projectId: teamProject.id });
const response = await authMemberAgent.get('/data-tables').query({ filter });
expect(response.statusCode).toBe(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].name).toBe('team-table');
});
test('should return empty results when filtering by a project the user is not a member of', async () => {
const teamProject = await createTeamProject('Team No Filter Access');
// member is NOT added to teamProject
const filter = JSON.stringify({ projectId: teamProject.id });
const response = await authMemberAgent.get('/data-tables').query({ filter });
expect(response.statusCode).toBe(200);
expect(response.body.data).toHaveLength(0);
});
test('should allow filtering by the user personal project id', async () => {
await createDataTable(memberPersonalProject, {
name: 'personal-only-table',
columns: [{ name: 'col', type: 'string' }],
});
const filter = JSON.stringify({ projectId: memberPersonalProject.id });
const response = await authMemberAgent.get('/data-tables').query({ filter });
expect(response.statusCode).toBe(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].name).toBe('personal-only-table');
});
});