mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Project based data table creation and transfer (#28323)
This commit is contained in:
parent
59edd6ae54
commit
24015b3449
13 changed files with 354 additions and 108 deletions
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 }, {}, {}, {}>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue