diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index f16758a89c..21d7c67910 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/project/__tests__/create-project.dto.test.ts b/packages/@n8n/api-types/src/dto/project/__tests__/create-project.dto.test.ts new file mode 100644 index 0000000000..42eb553030 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/__tests__/create-project.dto.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/project/__tests__/update-project.dto.test.ts b/packages/@n8n/api-types/src/dto/project/__tests__/update-project.dto.test.ts new file mode 100644 index 0000000000..86a8630026 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/__tests__/update-project.dto.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/project/create-project.dto.ts b/packages/@n8n/api-types/src/dto/project/create-project.dto.ts new file mode 100644 index 0000000000..cf748f5e13 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/create-project.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/project/delete-project.dto.ts b/packages/@n8n/api-types/src/dto/project/delete-project.dto.ts new file mode 100644 index 0000000000..cc8d8c2679 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/delete-project.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class DeleteProjectDto extends Z.class({ + transferId: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/project/update-project.dto.ts b/packages/@n8n/api-types/src/dto/project/update-project.dto.ts new file mode 100644 index 0000000000..b167ed88d3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/update-project.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index a003d54201..e304d62b5b 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts new file mode 100644 index 0000000000..9a1cf47414 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts @@ -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); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/project.schema.ts b/packages/@n8n/api-types/src/schemas/project.schema.ts new file mode 100644 index 0000000000..11c6cc2b37 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/project.schema.ts @@ -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; + +export const projectIconSchema = z.object({ + type: z.enum(['emoji', 'icon']), + value: z.string().min(1), +}); +export type ProjectIcon = z.infer; + +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; + +export const projectRelationSchema = z.object({ + userId: z.string(), + role: projectRoleSchema, +}); +export type ProjectRelation = z.infer; diff --git a/packages/cli/src/__tests__/project.test-data.ts b/packages/cli/src/__tests__/project.test-data.ts index 3ffac36fc8..cd176bd291 100644 --- a/packages/cli/src/__tests__/project.test-data.ts +++ b/packages/cli/src/__tests__/project.test-data.ts @@ -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; @@ -13,7 +13,7 @@ export const createRawProjectData = (payload: Partial): Project updatedAt: date(), id: nanoId.nanoid(), name: projectName, - type: 'personal' as ProjectType, + type: 'personal', ...payload, } as Project; }; diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index ab48f38c5b..665eba9636 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -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 { + async getAllProjects(req: AuthenticatedRequest): Promise { 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 { 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 { + async getProject( + req: AuthenticatedRequest, + _res: Response, + @Param('projectId') projectId: string, + ): Promise { 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, }); } } diff --git a/packages/cli/src/databases/entities/project-relation.ts b/packages/cli/src/databases/entities/project-relation.ts index 736ef2b223..eb73b62502 100644 --- a/packages/cli/src/databases/entities/project-relation.ts +++ b/packages/cli/src/databases/entities/project-relation.ts @@ -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') diff --git a/packages/cli/src/databases/entities/project.ts b/packages/cli/src/databases/entities/project.ts index aa867807fd..48f86fe0e9 100644 --- a/packages/cli/src/databases/entities/project.ts +++ b/packages/cli/src/databases/entities/project.ts @@ -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 }) diff --git a/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts index fe101fac7a..d7b8a2d47c 100644 --- a/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts +++ b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts @@ -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'; diff --git a/packages/cli/src/databases/repositories/project-relation.repository.ts b/packages/cli/src/databases/repositories/project-relation.repository.ts index 75aaed76df..89e5028b19 100644 --- a/packages/cli/src/databases/repositories/project-relation.repository.ts +++ b/packages/cli/src/databases/repositories/project-relation.repository.ts @@ -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 { diff --git a/packages/cli/src/databases/repositories/shared-credentials.repository.ts b/packages/cli/src/databases/repositories/shared-credentials.repository.ts index d7e074595c..f696a3bd59 100644 --- a/packages/cli/src/databases/repositories/shared-credentials.repository.ts +++ b/packages/cli/src/databases/repositories/shared-credentials.repository.ts @@ -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'; diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 0e72564571..0b21454f3b 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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; }; diff --git a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts index a693058f93..c55b10053d 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts @@ -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(); }, diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 5776549566..db5aa1365a 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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, - {}, - { - 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 }>; } // ---------------------------------- diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index aa93360287..43605939a3 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -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 { + async createTeamProject(adminUser: User, data: CreateProjectDto): Promise { 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, ): Promise { - 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'); diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index 7590dca2d5..e060c0dd81 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -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, diff --git a/packages/cli/src/workflows/workflow-sharing.service.ts b/packages/cli/src/workflows/workflow-sharing.service.ts index 220c9adaf9..d10ad45083 100644 --- a/packages/cli/src/workflows/workflow-sharing.service.ts +++ b/packages/cli/src/workflows/workflow-sharing.service.ts @@ -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'; diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 090de90603..98cecb6664 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -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 }); diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts index a75aad9566..5f7c7e1b27 100644 --- a/packages/cli/test/integration/project.api.test.ts +++ b/packages/cli/test/integration/project.api.test.ts @@ -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 = 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 = resp.body.data; diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 943d33bc35..3a1a9a77bc 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -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), diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts index 85888347cc..d8f56604a3 100644 --- a/packages/cli/test/integration/role.api.test.ts +++ b/packages/cli/test/integration/role.api.test.ts @@ -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'; diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts index bf83c6159c..b01aed3b3b 100644 --- a/packages/cli/test/integration/services/project.service.test.ts +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -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'; diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 6ca0fcfad2..9d61c2a667 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -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'; diff --git a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts index bfd068e2a0..3f54420873 100644 --- a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts @@ -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'); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index d376500484..b95304a583 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -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'; diff --git a/packages/editor-ui/src/api/projects.api.ts b/packages/editor-ui/src/api/projects.api.ts index 325feefc63..28e4f8abf7 100644 --- a/packages/editor-ui/src/api/projects.api.ts +++ b/packages/editor-ui/src/api/projects.api.ts @@ -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 => { return await makeRestApiRequest(context, 'GET', '/projects'); }; export const getMyProjects = async (context: IRestApiContext): Promise => { - 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 => { @@ -28,17 +21,17 @@ export const getProject = async (context: IRestApiContext, id: string): Promise< export const createProject = async ( context: IRestApiContext, - req: ProjectCreateRequest, + payload: CreateProjectDto, ): Promise => { - 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 => { - 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 ( diff --git a/packages/editor-ui/src/stores/projects.store.ts b/packages/editor-ui/src/stores/projects.store.ts index b795366f3b..8c652eb018 100644 --- a/packages/editor-ui/src/stores/projects.store.ts +++ b/packages/editor-ui/src/stores/projects.store.ts @@ -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 => { + const createProject = async (project: CreateProjectDto): Promise => { 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 => { - await projectsApi.updateProject(rootStore.restApiContext, projectData); - const projectIndex = myProjects.value.findIndex((p) => p.id === projectData.id); + const updateProject = async ( + id: Project['id'], + projectData: Required, + ): Promise => { + 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); } }; diff --git a/packages/editor-ui/src/types/projects.types.ts b/packages/editor-ui/src/types/projects.types.ts index 0ef0b67f90..0006ac4d48 100644 --- a/packages/editor-ui/src/types/projects.types.ts +++ b/packages/editor-ui/src/types/projects.types.ts @@ -31,10 +31,6 @@ export type ProjectListItem = ProjectSharingData & { role: ProjectRole; scopes?: Scope[]; }; -export type ProjectCreateRequest = { name: string; icon: ProjectIcon }; -export type ProjectUpdateRequest = Pick & { - relations: ProjectRelationPayload[]; -}; export type ProjectsCount = Record; export type ProjectIcon = { diff --git a/packages/editor-ui/src/views/ProjectSettings.vue b/packages/editor-ui/src/views/ProjectSettings.vue index 59ee76b935..2a64b95fe1 100644 --- a/packages/editor-ui/src/views/ProjectSettings.vue +++ b/packages/editor-ui/src/views/ProjectSettings.vue @@ -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,