refactor(core): Port over project request payloads to DTOs (#12528)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-01-09 13:47:23 +01:00 committed by GitHub
parent 44679b42aa
commit 5f1adefca7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 514 additions and 194 deletions

View file

@ -21,6 +21,10 @@ export { ForgotPasswordRequestDto } from './password-reset/forgot-password-reque
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
export { CreateProjectDto } from './project/create-project.dto';
export { UpdateProjectDto } from './project/update-project.dto';
export { DeleteProjectDto } from './project/delete-project.dto';
export { SamlAcsDto } from './saml/saml-acs.dto';
export { SamlPreferences } from './saml/saml-preferences.dto';
export { SamlToggleDto } from './saml/saml-toggle.dto';

View file

@ -0,0 +1,75 @@
import { CreateProjectDto } from '../create-project.dto';
describe('CreateProjectDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with just the name',
request: {
name: 'My Awesome Project',
},
},
{
name: 'with name and emoji icon',
request: {
name: 'My Awesome Project',
icon: {
type: 'emoji',
value: '🚀',
},
},
},
{
name: 'with name and regular icon',
request: {
name: 'My Awesome Project',
icon: {
type: 'icon',
value: 'blah',
},
},
},
])('should validate $name', ({ request }) => {
const result = CreateProjectDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing name',
request: { icon: { type: 'emoji', value: '🚀' } },
expectedErrorPath: ['name'],
},
{
name: 'empty name',
request: { name: '', icon: { type: 'emoji', value: '🚀' } },
expectedErrorPath: ['name'],
},
{
name: 'name too long',
request: { name: 'a'.repeat(256), icon: { type: 'emoji', value: '🚀' } },
expectedErrorPath: ['name'],
},
{
name: 'invalid icon type',
request: { name: 'My Awesome Project', icon: { type: 'invalid', value: '🚀' } },
expectedErrorPath: ['icon', 'type'],
},
{
name: 'invalid icon value',
request: { name: 'My Awesome Project', icon: { type: 'emoji', value: '' } },
expectedErrorPath: ['icon', 'value'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = CreateProjectDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,121 @@
import { UpdateProjectDto } from '../update-project.dto';
describe('UpdateProjectDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with just the name',
request: {
name: 'My Updated Project',
},
},
{
name: 'with name and emoji icon',
request: {
name: 'My Updated Project',
icon: {
type: 'emoji',
value: '🚀',
},
},
},
{
name: 'with name and regular icon',
request: {
name: 'My Updated Project',
icon: {
type: 'icon',
value: 'blah',
},
},
},
{
name: 'with relations',
request: {
relations: [
{
userId: 'user-123',
role: 'project:admin',
},
],
},
},
{
name: 'with all fields',
request: {
name: 'My Updated Project',
icon: {
type: 'emoji',
value: '🚀',
},
relations: [
{
userId: 'user-123',
role: 'project:admin',
},
],
},
},
])('should validate $name', ({ request }) => {
const result = UpdateProjectDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid name type',
request: { name: 123 },
expectedErrorPath: ['name'],
},
{
name: 'name too long',
request: { name: 'a'.repeat(256) },
expectedErrorPath: ['name'],
},
{
name: 'invalid icon type',
request: { icon: { type: 'invalid', value: '🚀' } },
expectedErrorPath: ['icon', 'type'],
},
{
name: 'invalid icon value',
request: { icon: { type: 'emoji', value: '' } },
expectedErrorPath: ['icon', 'value'],
},
{
name: 'invalid relations userId',
request: {
relations: [
{
userId: 123,
role: 'project:admin',
},
],
},
expectedErrorPath: ['relations', 0, 'userId'],
},
{
name: 'invalid relations role',
request: {
relations: [
{
userId: 'user-123',
role: 'invalid-role',
},
],
},
expectedErrorPath: ['relations', 0, 'role'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = UpdateProjectDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,8 @@
import { Z } from 'zod-class';
import { projectIconSchema, projectNameSchema } from '../../schemas/project.schema';
export class CreateProjectDto extends Z.class({
name: projectNameSchema,
icon: projectIconSchema.optional(),
}) {}

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class DeleteProjectDto extends Z.class({
transferId: z.string().optional(),
}) {}

View file

@ -0,0 +1,14 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import {
projectIconSchema,
projectNameSchema,
projectRelationSchema,
} from '../../schemas/project.schema';
export class UpdateProjectDto extends Z.class({
name: projectNameSchema.optional(),
icon: projectIconSchema.optional(),
relations: z.array(projectRelationSchema).optional(),
}) {}

View file

@ -10,3 +10,9 @@ export type { SendWorkerStatusMessage } from './push/worker';
export type { BannerName } from './schemas/bannerName.schema';
export { passwordSchema } from './schemas/password.schema';
export {
ProjectType,
ProjectIcon,
ProjectRole,
ProjectRelation,
} from './schemas/project.schema';

View file

@ -0,0 +1,105 @@
import {
projectNameSchema,
projectTypeSchema,
projectIconSchema,
projectRoleSchema,
projectRelationSchema,
} from '../project.schema';
describe('project.schema', () => {
describe('projectNameSchema', () => {
test.each([
{ name: 'valid name', value: 'My Project', expected: true },
{ name: 'empty name', value: '', expected: false },
{ name: 'name too long', value: 'a'.repeat(256), expected: false },
])('should validate $name', ({ value, expected }) => {
const result = projectNameSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectTypeSchema', () => {
test.each([
{ name: 'valid type: personal', value: 'personal', expected: true },
{ name: 'valid type: team', value: 'team', expected: true },
{ name: 'invalid type', value: 'invalid', expected: false },
])('should validate $name', ({ value, expected }) => {
const result = projectTypeSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectIconSchema', () => {
test.each([
{
name: 'valid emoji icon',
value: { type: 'emoji', value: '🚀' },
expected: true,
},
{
name: 'valid icon',
value: { type: 'icon', value: 'blah' },
expected: true,
},
{
name: 'invalid icon type',
value: { type: 'invalid', value: '🚀' },
expected: false,
},
{
name: 'empty icon value',
value: { type: 'emoji', value: '' },
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = projectIconSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectRoleSchema', () => {
test.each([
{ name: 'valid role: project:personalOwner', value: 'project:personalOwner', expected: true },
{ name: 'valid role: project:admin', value: 'project:admin', expected: true },
{ name: 'valid role: project:editor', value: 'project:editor', expected: true },
{ name: 'valid role: project:viewer', value: 'project:viewer', expected: true },
{ name: 'invalid role', value: 'invalid-role', expected: false },
])('should validate $name', ({ value, expected }) => {
const result = projectRoleSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectRelationSchema', () => {
test.each([
{
name: 'valid relation',
value: { userId: 'user-123', role: 'project:admin' },
expected: true,
},
{
name: 'invalid userId type',
value: { userId: 123, role: 'project:admin' },
expected: false,
},
{
name: 'invalid role',
value: { userId: 'user-123', role: 'invalid-role' },
expected: false,
},
{
name: 'missing userId',
value: { role: 'project:admin' },
expected: false,
},
{
name: 'missing role',
value: { userId: 'user-123' },
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = projectRelationSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
});

View file

@ -0,0 +1,26 @@
import { z } from 'zod';
export const projectNameSchema = z.string().min(1).max(255);
export const projectTypeSchema = z.enum(['personal', 'team']);
export type ProjectType = z.infer<typeof projectTypeSchema>;
export const projectIconSchema = z.object({
type: z.enum(['emoji', 'icon']),
value: z.string().min(1),
});
export type ProjectIcon = z.infer<typeof projectIconSchema>;
export const projectRoleSchema = z.enum([
'project:personalOwner', // personalOwner is only used for personal projects
'project:admin',
'project:editor',
'project:viewer',
]);
export type ProjectRole = z.infer<typeof projectRoleSchema>;
export const projectRelationSchema = z.object({
userId: z.string(),
role: projectRoleSchema,
});
export type ProjectRelation = z.infer<typeof projectRelationSchema>;

View file

@ -1,7 +1,7 @@
import { nanoId, date, firstName, lastName, email } from 'minifaker';
import 'minifaker/locales/en';
import type { Project, ProjectType } from '@/databases/entities/project';
import type { Project } from '@/databases/entities/project';
type RawProjectData = Pick<Project, 'name' | 'type' | 'createdAt' | 'updatedAt' | 'id'>;
@ -13,7 +13,7 @@ export const createRawProjectData = (payload: Partial<RawProjectData>): Project
updatedAt: date(),
id: nanoId.nanoid(),
name: projectName,
type: 'personal' as ProjectType,
type: 'personal',
...payload,
} as Project;
};

View file

@ -1,7 +1,9 @@
import { CreateProjectDto, DeleteProjectDto, UpdateProjectDto } from '@n8n/api-types';
import { combineScopes } from '@n8n/permissions';
import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, Not } from '@n8n/typeorm';
import { Response } from 'express';
import type { Project } from '@/databases/entities/project';
import { ProjectRepository } from '@/databases/repositories/project.repository';
@ -14,11 +16,15 @@ import {
Patch,
ProjectScope,
Delete,
Body,
Param,
Query,
} from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { ProjectRequest } from '@/requests';
import type { ProjectRequest } from '@/requests';
import { AuthenticatedRequest } from '@/requests';
import {
ProjectService,
TeamProjectOverQuotaError,
@ -36,7 +42,7 @@ export class ProjectController {
) {}
@Get('/')
async getAllProjects(req: ProjectRequest.GetAll): Promise<Project[]> {
async getAllProjects(req: AuthenticatedRequest): Promise<Project[]> {
return await this.projectsService.getAccessibleProjects(req.user);
}
@ -49,14 +55,9 @@ export class ProjectController {
@GlobalScope('project:create')
// Using admin as all plans that contain projects should allow admins at the very least
@Licensed('feat:projectRole:admin')
async createProject(req: ProjectRequest.Create) {
async createProject(req: AuthenticatedRequest, _res: Response, @Body payload: CreateProjectDto) {
try {
const project = await this.projectsService.createTeamProject(
req.body.name,
req.user,
undefined,
req.body.icon,
);
const project = await this.projectsService.createTeamProject(req.user, payload);
this.eventService.emit('team-project-created', {
userId: req.user.id,
@ -83,7 +84,8 @@ export class ProjectController {
@Get('/my-projects')
async getMyProjects(
req: ProjectRequest.GetMyProjects,
req: AuthenticatedRequest,
_res: Response,
): Promise<ProjectRequest.GetMyProjectsResponse> {
const relations = await this.projectsService.getProjectRelationsForUser(req.user);
const otherTeamProject = req.user.hasGlobalScope('project:read')
@ -98,10 +100,7 @@ export class ProjectController {
for (const pr of relations) {
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
this.projectRepository.create(pr.project),
{
role: pr.role,
scopes: req.query.includeScopes ? ([] as Scope[]) : undefined,
},
{ role: pr.role, scopes: [] },
);
if (result.scopes) {
@ -124,7 +123,7 @@ export class ProjectController {
// own this relationship in that case we use the global user role
// instead of the relation role, which is for another user.
role: req.user.role,
scopes: req.query.includeScopes ? [] : undefined,
scopes: [],
},
);
@ -148,7 +147,7 @@ export class ProjectController {
}
@Get('/personal')
async getPersonalProject(req: ProjectRequest.GetPersonalProject) {
async getPersonalProject(req: AuthenticatedRequest) {
const project = await this.projectsService.getPersonalProject(req.user);
if (!project) {
throw new NotFoundError('Could not find a personal project for this user');
@ -167,10 +166,14 @@ export class ProjectController {
@Get('/:projectId')
@ProjectScope('project:read')
async getProject(req: ProjectRequest.Get): Promise<ProjectRequest.ProjectWithRelations> {
async getProject(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId') projectId: string,
): Promise<ProjectRequest.ProjectWithRelations> {
const [{ id, name, icon, type }, relations] = await Promise.all([
this.projectsService.getProject(req.params.projectId),
this.projectsService.getProjectRelations(req.params.projectId),
this.projectsService.getProject(projectId),
this.projectsService.getProjectRelations(projectId),
]);
const myRelation = relations.find((r) => r.userId === req.user.id);
@ -197,13 +200,19 @@ export class ProjectController {
@Patch('/:projectId')
@ProjectScope('project:update')
async updateProject(req: ProjectRequest.Update) {
if (req.body.name) {
await this.projectsService.updateProject(req.body.name, req.params.projectId, req.body.icon);
async updateProject(
req: AuthenticatedRequest,
_res: Response,
@Body payload: UpdateProjectDto,
@Param('projectId') projectId: string,
) {
const { name, icon, relations } = payload;
if (name || icon) {
await this.projectsService.updateProject(projectId, { name, icon });
}
if (req.body.relations) {
if (relations) {
try {
await this.projectsService.syncProjectRelations(req.params.projectId, req.body.relations);
await this.projectsService.syncProjectRelations(projectId, relations);
} catch (e) {
if (e instanceof UnlicensedProjectRoleError) {
throw new BadRequestError(e.message);
@ -214,25 +223,30 @@ export class ProjectController {
this.eventService.emit('team-project-updated', {
userId: req.user.id,
role: req.user.role,
members: req.body.relations,
projectId: req.params.projectId,
members: relations,
projectId,
});
}
}
@Delete('/:projectId')
@ProjectScope('project:delete')
async deleteProject(req: ProjectRequest.Delete) {
await this.projectsService.deleteProject(req.user, req.params.projectId, {
migrateToProject: req.query.transferId,
async deleteProject(
req: AuthenticatedRequest,
_res: Response,
@Query query: DeleteProjectDto,
@Param('projectId') projectId: string,
) {
await this.projectsService.deleteProject(req.user, projectId, {
migrateToProject: query.transferId,
});
this.eventService.emit('team-project-deleted', {
userId: req.user.id,
role: req.user.role,
projectId: req.params.projectId,
removalType: req.query.transferId !== undefined ? 'transfer' : 'delete',
targetProjectId: req.query.transferId,
projectId,
removalType: query.transferId !== undefined ? 'transfer' : 'delete',
targetProjectId: query.transferId,
});
}
}

View file

@ -1,19 +1,13 @@
import { ProjectRole } from '@n8n/api-types';
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import { Project } from './project';
import { User } from './user';
// personalOwner is only used for personal projects
export type ProjectRole =
| 'project:personalOwner'
| 'project:admin'
| 'project:editor'
| 'project:viewer';
@Entity()
export class ProjectRelation extends WithTimestamps {
@Column()
@Column({ type: 'varchar' })
role: ProjectRole;
@ManyToOne('User', 'projectRelations')

View file

@ -1,3 +1,4 @@
import { ProjectIcon, ProjectType } from '@n8n/api-types';
import { Column, Entity, OneToMany } from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './abstract-entity';
@ -5,15 +6,12 @@ import type { ProjectRelation } from './project-relation';
import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow';
export type ProjectType = 'personal' | 'team';
export type ProjectIcon = { type: 'emoji' | 'icon'; value: string } | null;
@Entity()
export class Project extends WithTimestampsAndStringId {
@Column({ length: 255 })
name: string;
@Column({ length: 36 })
@Column({ type: 'varchar', length: 36 })
type: ProjectType;
@Column({ type: 'json', nullable: true })

View file

@ -1,7 +1,7 @@
import type { ProjectRole } from '@n8n/api-types';
import { ApplicationError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
import { generateNanoId } from '@/databases/utils/generators';

View file

@ -1,7 +1,8 @@
import type { ProjectRole } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectRelation, type ProjectRole } from '../entities/project-relation';
import { ProjectRelation } from '../entities/project-relation';
@Service()
export class ProjectRelationRepository extends Repository<ProjectRelation> {

View file

@ -1,3 +1,4 @@
import type { ProjectRole } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { EntityManager, FindOptionsRelations, FindOptionsWhere } from '@n8n/typeorm';
@ -6,7 +7,6 @@ import { DataSource, In, Not, Repository } from '@n8n/typeorm';
import { RoleService } from '@/services/role.service';
import type { Project } from '../entities/project';
import type { ProjectRole } from '../entities/project-relation';
import { type CredentialSharingRole, SharedCredentials } from '../entities/shared-credentials';
import type { User } from '../entities/user';

View file

@ -1,4 +1,4 @@
import type { AuthenticationMethod } from '@n8n/api-types';
import type { AuthenticationMethod, ProjectRelation } from '@n8n/api-types';
import type {
IPersonalizationSurveyAnswersV4,
IRun,
@ -7,7 +7,6 @@ import type {
} from 'n8n-workflow';
import type { AuthProviderType } from '@/databases/entities/auth-identity';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { GlobalRole, User } from '@/databases/entities/user';
import type { IWorkflowDb } from '@/interfaces';
@ -351,10 +350,7 @@ export type RelayEventMap = {
'team-project-updated': {
userId: string;
role: GlobalRole;
members: Array<{
userId: string;
role: ProjectRole;
}>;
members: ProjectRelation[];
projectId: string;
};

View file

@ -1,25 +1,28 @@
import { CreateProjectDto, DeleteProjectDto, UpdateProjectDto } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Response } from 'express';
import { ProjectController } from '@/controllers/project.controller';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import type { PaginatedRequest } from '@/public-api/types';
import type { ProjectRequest } from '@/requests';
import type { AuthenticatedRequest } from '@/requests';
import { globalScope, isLicensed, validCursor } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
type Create = ProjectRequest.Create;
type Update = ProjectRequest.Update;
type Delete = ProjectRequest.Delete;
type GetAll = PaginatedRequest;
export = {
createProject: [
isLicensed('feat:projectRole:admin'),
globalScope('project:create'),
async (req: Create, res: Response) => {
const project = await Container.get(ProjectController).createProject(req);
async (req: AuthenticatedRequest, res: Response) => {
const payload = CreateProjectDto.safeParse(req.body);
if (payload.error) {
return res.status(400).json(payload.error.errors[0]);
}
const project = await Container.get(ProjectController).createProject(req, res, payload.data);
return res.status(201).json(project);
},
@ -27,8 +30,18 @@ export = {
updateProject: [
isLicensed('feat:projectRole:admin'),
globalScope('project:update'),
async (req: Update, res: Response) => {
await Container.get(ProjectController).updateProject(req);
async (req: AuthenticatedRequest<{ projectId: string }>, res: Response) => {
const payload = UpdateProjectDto.safeParse(req.body);
if (payload.error) {
return res.status(400).json(payload.error.errors[0]);
}
await Container.get(ProjectController).updateProject(
req,
res,
payload.data,
req.params.projectId,
);
return res.status(204).send();
},
@ -36,8 +49,18 @@ export = {
deleteProject: [
isLicensed('feat:projectRole:admin'),
globalScope('project:delete'),
async (req: Delete, res: Response) => {
await Container.get(ProjectController).deleteProject(req);
async (req: AuthenticatedRequest<{ projectId: string }>, res: Response) => {
const query = DeleteProjectDto.safeParse(req.query);
if (query.error) {
return res.status(400).json(query.error.errors[0]);
}
await Container.get(ProjectController).deleteProject(
req,
res,
query.data,
req.params.projectId,
);
return res.status(204).send();
},

View file

@ -1,3 +1,4 @@
import type { ProjectIcon, ProjectRole, ProjectType } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions';
import type express from 'express';
import type {
@ -9,14 +10,13 @@ import type {
} from 'n8n-workflow';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { Project, ProjectIcon, ProjectType } from '@/databases/entities/project';
import type { Project } from '@/databases/entities/project';
import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user';
import type { Variables } from '@/databases/entities/variables';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { WorkflowHistory } from '@/databases/entities/workflow-history';
import type { SecretsProvider, SecretsProviderState } from '@/interfaces';
import type { ProjectRole } from './databases/entities/project-relation';
import type { ScopesField } from './services/role.service';
export type APIRequest<
@ -388,32 +388,10 @@ export declare namespace ActiveWorkflowRequest {
// ----------------------------------
export declare namespace ProjectRequest {
type GetAll = AuthenticatedRequest<{}, Project[]>;
type Create = AuthenticatedRequest<
{},
Project,
{
name: string;
icon?: ProjectIcon;
}
>;
type GetMyProjects = AuthenticatedRequest<
{},
Array<Project & { role: ProjectRole }>,
{},
{
includeScopes?: boolean;
}
>;
type GetMyProjectsResponse = Array<
Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] }
>;
type GetPersonalProject = AuthenticatedRequest<{}, Project>;
type ProjectRelationPayload = { userId: string; role: ProjectRole };
type ProjectRelationResponse = {
id: string;
email: string;
@ -429,18 +407,6 @@ export declare namespace ProjectRequest {
relations: ProjectRelationResponse[];
scopes: Scope[];
};
type Get = AuthenticatedRequest<{ projectId: string }, {}>;
type Update = AuthenticatedRequest<
{ projectId: string },
{},
{
name?: string;
relations?: ProjectRelationPayload[];
icon?: { type: 'icon' | 'emoji'; value: string };
}
>;
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
}
// ----------------------------------

View file

@ -1,3 +1,4 @@
import type { CreateProjectDto, ProjectRole, ProjectType, UpdateProjectDto } from '@n8n/api-types';
import { Container, Service } from '@n8n/di';
import { type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
@ -7,10 +8,8 @@ import { In, Not } from '@n8n/typeorm';
import { ApplicationError } from 'n8n-workflow';
import { UNLIMITED_LICENSE_QUOTA } from '@/constants';
import type { ProjectIcon, ProjectType } from '@/databases/entities/project';
import { Project } from '@/databases/entities/project';
import { ProjectRelation } from '@/databases/entities/project-relation';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
@ -168,12 +167,7 @@ export class ProjectService {
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
}
async createTeamProject(
name: string,
adminUser: User,
id?: string,
icon?: ProjectIcon,
): Promise<Project> {
async createTeamProject(adminUser: User, data: CreateProjectDto): Promise<Project> {
const limit = this.license.getTeamProjectLimit();
if (
limit !== UNLIMITED_LICENSE_QUOTA &&
@ -183,12 +177,7 @@ export class ProjectService {
}
const project = await this.projectRepository.save(
this.projectRepository.create({
id,
name,
icon,
type: 'team',
}),
this.projectRepository.create({ ...data, type: 'team' }),
);
// Link admin
@ -198,20 +187,10 @@ export class ProjectService {
}
async updateProject(
name: string,
projectId: string,
icon?: { type: 'icon' | 'emoji'; value: string },
data: Pick<UpdateProjectDto, 'name' | 'icon'>,
): Promise<Project> {
const result = await this.projectRepository.update(
{
id: projectId,
type: 'team',
},
{
name,
icon,
},
);
const result = await this.projectRepository.update({ id: projectId, type: 'team' }, data);
if (!result.affected) {
throw new ForbiddenError('Project not found');

View file

@ -1,9 +1,10 @@
import type { ProjectRole } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { combineScopes, type Resource, type Scope } from '@n8n/permissions';
import { ApplicationError } from 'n8n-workflow';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { ProjectRelation, ProjectRole } from '@/databases/entities/project-relation';
import type { ProjectRelation } from '@/databases/entities/project-relation';
import type {
CredentialSharingRole,
SharedCredentials,

View file

@ -1,9 +1,9 @@
import type { ProjectRole } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { WorkflowSharingRole } from '@/databases/entities/shared-workflow';
import type { User } from '@/databases/entities/user';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';

View file

@ -1,10 +1,10 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { In } from '@n8n/typeorm';
import config from '@/config';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
@ -226,12 +226,12 @@ describe('GET /credentials', () => {
//
// ARRANGE
//
const project1 = await projectService.createTeamProject('Team Project', member);
const project1 = await projectService.createTeamProject(member, { name: 'Team Project' });
await projectService.addUser(project1.id, anotherMember.id, 'project:editor');
// anotherMember should see this one
const credential1 = await saveCredential(randomCredentialPayload(), { project: project1 });
const project2 = await projectService.createTeamProject('Team Project', member);
const project2 = await projectService.createTeamProject(member, { name: 'Team Project' });
// anotherMember should NOT see this one
await saveCredential(randomCredentialPayload(), { project: project2 });

View file

@ -1,10 +1,10 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { EntityNotFoundError } from '@n8n/typeorm';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { GlobalRole } from '@/databases/entities/user';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
@ -177,11 +177,7 @@ describe('GET /projects/my-projects', () => {
//
// ACT
//
const resp = await testServer
.authAgentFor(testUser1)
.get('/projects/my-projects')
.query({ includeScopes: true })
.expect(200);
const resp = await testServer.authAgentFor(testUser1).get('/projects/my-projects').expect(200);
const respProjects: Array<Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] }> =
resp.body.data;
@ -258,11 +254,7 @@ describe('GET /projects/my-projects', () => {
//
// ACT
//
const resp = await testServer
.authAgentFor(ownerUser)
.get('/projects/my-projects')
.query({ includeScopes: true })
.expect(200);
const resp = await testServer.authAgentFor(ownerUser).get('/projects/my-projects').expect(200);
const respProjects: Array<Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] }> =
resp.body.data;

View file

@ -263,8 +263,12 @@ describe('GET /workflows', () => {
test('for owner, should return all workflows filtered by `projectId`', async () => {
license.setQuota('quota:maxTeamProjects', -1);
const firstProject = await Container.get(ProjectService).createTeamProject('First', owner);
const secondProject = await Container.get(ProjectService).createTeamProject('Second', member);
const firstProject = await Container.get(ProjectService).createTeamProject(owner, {
name: 'First',
});
const secondProject = await Container.get(ProjectService).createTeamProject(member, {
name: 'Second',
});
await Promise.all([
createWorkflow({ name: 'First workflow' }, firstProject),
@ -285,10 +289,9 @@ describe('GET /workflows', () => {
test('for member, should return all member-accessible workflows filtered by `projectId`', async () => {
license.setQuota('quota:maxTeamProjects', -1);
const otherProject = await Container.get(ProjectService).createTeamProject(
'Other project',
member,
);
const otherProject = await Container.get(ProjectService).createTeamProject(member, {
name: 'Other project',
});
await Promise.all([
createWorkflow({}, member),

View file

@ -1,7 +1,7 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { CredentialSharingRole } from '@/databases/entities/shared-credentials';
import type { WorkflowSharingRole } from '@/databases/entities/shared-workflow';
import type { GlobalRole } from '@/databases/entities/user';

View file

@ -1,7 +1,7 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { ProjectRole } from '@/databases/entities/project-relation';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { ProjectService } from '@/services/project.service.ee';

View file

@ -1,7 +1,8 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Project } from '@/databases/entities/project';
import type { ProjectRelation, ProjectRole } from '@/databases/entities/project-relation';
import type { ProjectRelation } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';

View file

@ -72,7 +72,7 @@ describe('WorkflowSharingService', () => {
//
// ARRANGE
//
const project = await projectService.createTeamProject('Team Project', member);
const project = await projectService.createTeamProject(member, { name: 'Team Project' });
await projectService.addUser(project.id, anotherMember.id, 'project:admin');
const workflow = await createWorkflow(undefined, project);
@ -93,9 +93,9 @@ describe('WorkflowSharingService', () => {
//
// ARRANGE
//
const project1 = await projectService.createTeamProject('Team Project 1', member);
const project1 = await projectService.createTeamProject(member, { name: 'Team Project 1' });
const workflow1 = await createWorkflow(undefined, project1);
const project2 = await projectService.createTeamProject('Team Project 2', member);
const project2 = await projectService.createTeamProject(member, { name: 'Team Project 2' });
const workflow2 = await createWorkflow(undefined, project2);
await projectService.addUser(project1.id, anotherMember.id, 'project:admin');
await projectService.addUser(project2.id, anotherMember.id, 'project:viewer');

View file

@ -1,3 +1,4 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { ApplicationError, WorkflowActivationError, type INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
@ -5,7 +6,6 @@ import { v4 as uuid } from 'uuid';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';

View file

@ -1,21 +1,14 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type {
Project,
ProjectCreateRequest,
ProjectListItem,
ProjectUpdateRequest,
ProjectsCount,
} from '@/types/projects.types';
import type { Project, ProjectListItem, ProjectsCount } from '@/types/projects.types';
import type { CreateProjectDto, UpdateProjectDto } from '@n8n/api-types';
export const getAllProjects = async (context: IRestApiContext): Promise<ProjectListItem[]> => {
return await makeRestApiRequest(context, 'GET', '/projects');
};
export const getMyProjects = async (context: IRestApiContext): Promise<ProjectListItem[]> => {
return await makeRestApiRequest(context, 'GET', '/projects/my-projects', {
includeScopes: true,
});
return await makeRestApiRequest(context, 'GET', '/projects/my-projects');
};
export const getPersonalProject = async (context: IRestApiContext): Promise<Project> => {
@ -28,17 +21,17 @@ export const getProject = async (context: IRestApiContext, id: string): Promise<
export const createProject = async (
context: IRestApiContext,
req: ProjectCreateRequest,
payload: CreateProjectDto,
): Promise<Project> => {
return await makeRestApiRequest(context, 'POST', '/projects', req);
return await makeRestApiRequest(context, 'POST', '/projects', payload);
};
export const updateProject = async (
context: IRestApiContext,
req: ProjectUpdateRequest,
id: Project['id'],
payload: UpdateProjectDto,
): Promise<void> => {
const { id, name, icon, relations } = req;
await makeRestApiRequest(context, 'PATCH', `/projects/${id}`, { name, icon, relations });
await makeRestApiRequest(context, 'PATCH', `/projects/${id}`, payload);
};
export const deleteProject = async (

View file

@ -5,13 +5,7 @@ import { useRootStore } from '@/stores/root.store';
import * as projectsApi from '@/api/projects.api';
import * as workflowsEEApi from '@/api/workflows.ee';
import * as credentialsEEApi from '@/api/credentials.ee';
import type {
Project,
ProjectCreateRequest,
ProjectListItem,
ProjectUpdateRequest,
ProjectsCount,
} from '@/types/projects.types';
import type { Project, ProjectListItem, ProjectsCount } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { useSettingsStore } from '@/stores/settings.store';
import { hasPermission } from '@/utils/rbac/permissions';
@ -21,6 +15,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { STORES } from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import { getResourcePermissions } from '@/permissions';
import type { CreateProjectDto, UpdateProjectDto } from '@n8n/api-types';
export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const route = useRoute();
@ -112,26 +107,30 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
currentProject.value = await fetchProject(id);
};
const createProject = async (project: ProjectCreateRequest): Promise<Project> => {
const createProject = async (project: CreateProjectDto): Promise<Project> => {
const newProject = await projectsApi.createProject(rootStore.restApiContext, project);
await getProjectsCount();
myProjects.value = [...myProjects.value, newProject as unknown as ProjectListItem];
return newProject;
};
const updateProject = async (projectData: ProjectUpdateRequest): Promise<void> => {
await projectsApi.updateProject(rootStore.restApiContext, projectData);
const projectIndex = myProjects.value.findIndex((p) => p.id === projectData.id);
const updateProject = async (
id: Project['id'],
projectData: Required<UpdateProjectDto>,
): Promise<void> => {
await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
const projectIndex = myProjects.value.findIndex((p) => p.id === id);
const { name, icon } = projectData;
if (projectIndex !== -1) {
myProjects.value[projectIndex].name = projectData.name;
myProjects.value[projectIndex].icon = projectData.icon;
myProjects.value[projectIndex].name = name;
myProjects.value[projectIndex].icon = icon;
}
if (currentProject.value) {
currentProject.value.name = projectData.name;
currentProject.value.icon = projectData.icon;
currentProject.value.name = name;
currentProject.value.icon = icon;
}
if (projectData.relations) {
await getProject(projectData.id);
await getProject(id);
}
};

View file

@ -31,10 +31,6 @@ export type ProjectListItem = ProjectSharingData & {
role: ProjectRole;
scopes?: Scope[];
};
export type ProjectCreateRequest = { name: string; icon: ProjectIcon };
export type ProjectUpdateRequest = Pick<Project, 'id' | 'name' | 'icon'> & {
relations: ProjectRelationPayload[];
};
export type ProjectsCount = Record<ProjectType, number>;
export type ProjectIcon = {

View file

@ -192,9 +192,8 @@ const updateProject = async () => {
return;
}
try {
await projectsStore.updateProject({
id: projectsStore.currentProject.id,
name: formData.value.name,
await projectsStore.updateProject(projectsStore.currentProject.id, {
name: formData.value.name!,
icon: projectIcon.value,
relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id,