diff --git a/packages/@n8n/db/src/repositories/project.repository.ts b/packages/@n8n/db/src/repositories/project.repository.ts index d034c58ba64..154d395c233 100644 --- a/packages/@n8n/db/src/repositories/project.repository.ts +++ b/packages/@n8n/db/src/repositories/project.repository.ts @@ -1,6 +1,6 @@ import { Service } from '@n8n/di'; import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; -import { Brackets, DataSource, Repository } from '@n8n/typeorm'; +import { Brackets, DataSource, In, Not, Repository } from '@n8n/typeorm'; import { Project } from '../entities'; @@ -43,6 +43,10 @@ export class ProjectRepository extends Repository { }); } + async findTeamProjectsExcluding(excludedProjectIds: string[]): Promise { + return await this.findBy({ type: 'team', id: Not(In(excludedProjectIds)) }); + } + async getAccessibleProjectsByExactName( userId: string, name: string, diff --git a/packages/cli/src/controllers/__tests__/project.controller.test.ts b/packages/cli/src/controllers/__tests__/project.controller.test.ts index 73f7a92ff1a..9dd16320d33 100644 --- a/packages/cli/src/controllers/__tests__/project.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/project.controller.test.ts @@ -1,27 +1,19 @@ import { ListProjectsQueryDto } from '@n8n/api-types'; -import type { AuthenticatedRequest, ProjectRepository } from '@n8n/db'; +import type { AuthenticatedRequest } from '@n8n/db'; import type { Response } from 'express'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { ProjectController } from '@/controllers/project.controller'; -import type { EventService } from '@/events/event.service'; import type { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import type { ProjectService } from '@/services/project.service.ee'; -import type { UserManagementMailer } from '@/user-management/email'; describe('ProjectController', () => { - const eventService = mock(); const projectsService = mock(); - const projectRepository = mock(); - const userManagementMailer = mock(); const provisioningService = mock(); const controller = new ProjectController( projectsService as unknown as ProjectService, - projectRepository as unknown as ProjectRepository, - eventService as unknown as EventService, - userManagementMailer as unknown as UserManagementMailer, provisioningService as unknown as ProvisioningService, ); @@ -48,7 +40,10 @@ describe('ProjectController', () => { { id: 'p1', name: 'Project 1' }, { id: 'p2', name: 'Project 2' }, ]; - (projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue([projects, 2]); + (projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue({ + projects, + count: 2, + }); (projectsService.addUserScopes as Mock).mockResolvedValue(projects); const res = makeRes(); @@ -63,7 +58,10 @@ describe('ProjectController', () => { it('returns bare array when no pagination params given', async () => { const projects = [{ id: 'p1', name: 'Project 1' }]; - (projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue([projects, 1]); + (projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue({ + projects, + count: 1, + }); const res = makeRes(); // Simulate DTO-parsed output: when no query params are provided, @@ -90,7 +88,10 @@ describe('ProjectController', () => { role: 'global:member', scopes: ['user:list'], })); - (projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue([projects, 2]); + (projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue({ + projects, + count: 2, + }); (projectsService.addUserScopes as Mock).mockResolvedValue(enriched); const res = makeRes(); @@ -104,7 +105,10 @@ describe('ProjectController', () => { }); it('always returns the { count, data } envelope (no bare-array path)', async () => { - (projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue([[], 0]); + (projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue({ + projects: [], + count: 0, + }); (projectsService.addUserScopes as Mock).mockResolvedValue([]); const res = makeRes(); @@ -118,31 +122,7 @@ describe('ProjectController', () => { }); }); - it('emits team-project-updated with custom telemetry tag count on updateProject', async () => { - const projectId = 'p1'; - const payload = { - name: 'Updated Project', - customTelemetryTags: [ - { key: 'env', value: 'production' }, - { key: 'team', value: 'engineering' }, - ], - }; - - const res = makeRes(); - - await controller.updateProject(req, res, payload as any, projectId); - - expect(projectsService.updateProject).toHaveBeenCalledWith(projectId, payload); - expect(projectsService.getProjectRelations).not.toHaveBeenCalled(); - expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { - userId: 'actor-user', - role: 'global:owner', - projectId, - otelProjectCustomTagsCount: 2, - }); - }); - - it('emits team-project-updated without custom telemetry tag count on updateProject without tags', async () => { + it('delegates updateProject to the service with the acting user', async () => { const projectId = 'p1'; const payload = { name: 'Updated Project' }; @@ -150,16 +130,10 @@ describe('ProjectController', () => { await controller.updateProject(req, res, payload as any, projectId); - expect(projectsService.updateProject).toHaveBeenCalledWith(projectId, payload); - expect(projectsService.getProjectRelations).not.toHaveBeenCalled(); - expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { - userId: 'actor-user', - role: 'global:owner', - projectId, - }); + expect(projectsService.updateProject).toHaveBeenCalledWith(req.user, projectId, payload); }); - it('emits team-project-updated with full members list on addProjectUsers', async () => { + it('delegates addProjectUsers to the service with the acting user', async () => { // Arrange const projectId = 'p1'; const payload = { relations: [{ userId: 'u2', role: 'project:viewer' as const }] }; @@ -171,11 +145,6 @@ describe('ProjectController', () => { conflicts: [], }); - (projectsService.getProjectRelations as Mock).mockResolvedValue([ - { userId: 'u1', role: { slug: 'project:admin' } }, - { userId: 'u2', role: { slug: 'project:viewer' } }, - ]); - const res = makeRes(); // Act @@ -183,35 +152,16 @@ describe('ProjectController', () => { // Assert expect(projectsService.addUsersWithConflictSemantics).toHaveBeenCalledWith( + req.user, projectId, payload.relations, ); - expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { - userId: 'actor-user', - role: 'global:owner', - members: [ - { userId: 'u1', role: 'project:admin' }, - { userId: 'u2', role: 'project:viewer' }, - ], - projectId, - }); - - // Verify mailer called for new sharees - expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({ - sharer: req.user, - newSharees: payload.relations, - project: { id: projectId, name: 'Project' }, - }); }); - it('emits team-project-updated on changeProjectUserRole and returns 204', async () => { + it('delegates changeProjectUserRole to the service and returns 204', async () => { // Arrange const projectId = 'p2'; provisioningService.isProjectRoleManaged.mockResolvedValue(false); - (projectsService.getProjectRelations as Mock).mockResolvedValue([ - { userId: 'u1', role: { slug: 'project:admin' } }, - { userId: 'u2', role: { slug: 'project:editor' } }, - ]); const res = makeRes(); @@ -221,26 +171,19 @@ describe('ProjectController', () => { } as any); // Assert - expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { - userId: 'actor-user', - role: 'global:owner', - members: [ - { userId: 'u1', role: 'project:admin' }, - { userId: 'u2', role: 'project:editor' }, - ], + expect(projectsService.changeUserRoleInProject).toHaveBeenCalledWith( + req.user, projectId, - }); + 'u2', + 'project:editor', + ); expect(res.status).toHaveBeenCalledWith(204); }); - it('emits team-project-updated on deleteProjectUser and returns 204', async () => { + it('delegates deleteProjectUser to the service and returns 204', async () => { // Arrange const projectId = 'p3'; provisioningService.isProjectRoleManaged.mockResolvedValue(false); - (projectsService.getProjectRelations as Mock).mockResolvedValue([ - { userId: 'u1', role: { slug: 'project:admin' } }, - { userId: 'u3', role: { slug: 'project:viewer' } }, - ]); const res = makeRes(); @@ -248,16 +191,7 @@ describe('ProjectController', () => { await controller.deleteProjectUser(req, res, projectId, 'u2'); // Assert - expect(projectsService.deleteUserFromProject).toHaveBeenCalledWith(projectId, 'u2'); - expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { - userId: 'actor-user', - role: 'global:owner', - members: [ - { userId: 'u1', role: 'project:admin' }, - { userId: 'u3', role: 'project:viewer' }, - ], - projectId, - }); + expect(projectsService.deleteUserFromProject).toHaveBeenCalledWith(req.user, projectId, 'u2'); expect(res.status).toHaveBeenCalledWith(204); }); @@ -279,12 +213,6 @@ describe('ProjectController', () => { conflicts, }); - (projectsService.getProjectRelations as Mock).mockResolvedValue([ - { userId: 'u1', role: { slug: 'project:admin' } }, - { userId: 'u4', role: { slug: 'project:viewer' } }, - { userId: 'u5', role: { slug: 'project:viewer' } }, - ]); - const res = makeRes(); // Act @@ -295,25 +223,6 @@ describe('ProjectController', () => { // Assert: 201 with conflicts body expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith({ conflicts }); - - // Mailer is called for newly added sharees - expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({ - sharer: req.user, - newSharees: added, - project: { id: projectId, name: 'Project' }, - }); - - // Telemetry event has full members list - expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { - userId: 'actor-user', - role: 'global:owner', - members: [ - { userId: 'u1', role: 'project:admin' }, - { userId: 'u4', role: 'project:viewer' }, - { userId: 'u5', role: 'project:viewer' }, - ], - projectId, - }); }); describe('managed project roles', () => { @@ -329,8 +238,6 @@ describe('ProjectController', () => { ).rejects.toThrow('Project roles are managed automatically'); expect(projectsService.addUsersWithConflictSemantics).not.toHaveBeenCalled(); - expect(userManagementMailer.notifyProjectShared).not.toHaveBeenCalled(); - expect(eventService.emit).not.toHaveBeenCalled(); }); it('blocks changeProjectUserRole when project roles are managed', async () => { @@ -345,7 +252,6 @@ describe('ProjectController', () => { ).rejects.toThrow('Project roles are managed automatically'); expect(projectsService.changeUserRoleInProject).not.toHaveBeenCalled(); - expect(eventService.emit).not.toHaveBeenCalled(); }); it('blocks deleteProjectUser when project roles are managed', async () => { @@ -358,7 +264,6 @@ describe('ProjectController', () => { ); expect(projectsService.deleteUserFromProject).not.toHaveBeenCalled(); - expect(eventService.emit).not.toHaveBeenCalled(); }); it.each([true, false])('exposes rolesManaged=%s on getProject', async (managed) => { diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 0c880cd404f..d93488bac0c 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -6,7 +6,7 @@ import { ChangeUserRoleInProject, ListProjectsQueryDto, } from '@n8n/api-types'; -import { AuthenticatedRequest, ProjectRepository } from '@n8n/db'; +import { AuthenticatedRequest } from '@n8n/db'; import { Get, Post, @@ -20,15 +20,12 @@ import { Param, Query, } from '@n8n/decorators'; -import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions'; -import type { Scope } from '@n8n/permissions'; -import { In, Not } from '@n8n/typeorm'; +import { combineScopes, getAuthPrincipalScopes } from '@n8n/permissions'; import { Response } from 'express'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import type { ProjectRequest } from '@/requests'; import { @@ -36,15 +33,11 @@ import { TeamProjectOverQuotaError, UnlicensedProjectRoleError, } from '@/services/project.service.ee'; -import { UserManagementMailer } from '@/user-management/email'; @RestController('/projects') export class ProjectController { constructor( private readonly projectsService: ProjectService, - private readonly projectRepository: ProjectRepository, - private readonly eventService: EventService, - private readonly userManagementMailer: UserManagementMailer, private readonly provisioningService: ProvisioningService, ) {} @@ -54,7 +47,7 @@ export class ProjectController { res: Response, @Query payload: ListProjectsQueryDto, ) { - const [data, count] = await this.projectsService.getAccessibleProjectsAndCount( + const { projects, count } = await this.projectsService.getAccessibleProjectsAndCount( req.user, payload, ); @@ -63,10 +56,10 @@ export class ProjectController { // with role and scopes enriched per project. // Otherwise return a bare array for backward compatibility with existing callers. if (payload.take !== undefined || payload.skip !== undefined) { - const enriched = await this.projectsService.addUserScopes(req.user, data); + const enriched = await this.projectsService.addUserScopes(req.user, projects); return res.json({ count, data: enriched }); } - return data; + return projects; } @Get('/count') @@ -86,11 +79,11 @@ export class ProjectController { res: Response, @Query payload: ListProjectsQueryDto, ) { - const [data, count] = await this.projectsService.getShareableProjectsAndCount( + const { projects, count } = await this.projectsService.getShareableProjectsAndCount( req.user, payload, ); - const enriched = await this.projectsService.addUserScopes(req.user, data); + const enriched = await this.projectsService.addUserScopes(req.user, projects); return res.json({ count, data: enriched }); } @@ -102,26 +95,10 @@ export class ProjectController { try { const project = await this.projectsService.createTeamProject(req.user, payload); - this.eventService.emit('team-project-created', { - userId: req.user.id, - role: req.user.role.slug, - uiContext: payload.uiContext, - }); - - const relation = await this.projectsService.getProjectRelationForUserAndProject( - req.user.id, - project.id, - ); - return { ...project, role: 'project:admin', - scopes: [ - ...combineScopes({ - global: getAuthPrincipalScopes(req.user), - project: relation?.role.scopes.map((scope) => scope.slug) ?? [], - }), - ], + scopes: await this.projectsService.getProjectScopesForUser(req.user, project.id), }; } catch (e) { if (e instanceof TeamProjectOverQuotaError) { @@ -136,61 +113,7 @@ export class ProjectController { req: AuthenticatedRequest, _res: Response, ): Promise { - const relations = await this.projectsService.getProjectRelationsForUser(req.user); - const otherTeamProject = hasGlobalScope(req.user, 'project:read') - ? await this.projectRepository.findBy({ - type: 'team', - id: Not(In(relations.map((pr) => pr.projectId))), - }) - : []; - - const results: ProjectRequest.GetMyProjectsResponse = []; - - for (const pr of relations) { - const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( - this.projectRepository.create(pr.project), - { role: pr.role.slug, scopes: [] }, - ); - - if (result.scopes) { - result.scopes.push( - ...combineScopes({ - global: getAuthPrincipalScopes(req.user), - project: pr.role.scopes.map((scope) => scope.slug), - }), - ); - } - - results.push(result); - } - - for (const project of otherTeamProject) { - const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( - this.projectRepository.create(project), - { - // If the user has the global `project:read` scope then they may not - // 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.slug, - scopes: [], - }, - ); - - if (result.scopes) { - result.scopes.push(...combineScopes({ global: getAuthPrincipalScopes(req.user) })); - } - - results.push(result); - } - - // Deduplicate and sort scopes - for (const result of results) { - if (result.scopes) { - result.scopes = [...new Set(result.scopes)].sort(); - } - } - - return results; + return await this.projectsService.getMyProjects(req.user); } @Get('/personal') @@ -200,16 +123,7 @@ export class ProjectController { throw new NotFoundError('Could not find a personal project for this user'); } - const relation = await this.projectsService.getProjectRelationForUserAndProject( - req.user.id, - project.id, - ); - const scopes: Scope[] = [ - ...combineScopes({ - global: getAuthPrincipalScopes(req.user), - project: relation?.role.scopes.map((scope) => scope.slug) ?? [], - }), - ]; + const scopes = await this.projectsService.getProjectScopesForUser(req.user, project.id); return { ...project, scopes, @@ -265,15 +179,7 @@ export class ProjectController { @Body payload: UpdateProjectDto, @Param('projectId') projectId: string, ) { - await this.projectsService.updateProject(projectId, payload); - this.eventService.emit('team-project-updated', { - userId: req.user.id, - role: req.user.role.slug, - projectId, - ...(payload.customTelemetryTags !== undefined - ? { otelProjectCustomTagsCount: payload.customTelemetryTags.length } - : {}), - }); + await this.projectsService.updateProject(req.user, projectId, payload); } /** Throws when project roles are provisioned automatically, so manual membership changes are disallowed. */ @@ -295,24 +201,11 @@ export class ProjectController { ) { await this.assertProjectRolesNotManaged(); try { - const { added, conflicts, project } = - await this.projectsService.addUsersWithConflictSemantics(projectId, payload.relations); - - if (added.length > 0) { - await this.userManagementMailer.notifyProjectShared({ - sharer: req.user, - newSharees: added, - project: { id: project.id, name: project.name }, - }); - } - - const relations = await this.projectsService.getProjectRelations(projectId); - this.eventService.emit('team-project-updated', { - userId: req.user.id, - role: req.user.role.slug, - members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })), + const { added, conflicts } = await this.projectsService.addUsersWithConflictSemantics( + req.user, projectId, - }); + payload.relations, + ); // Response semantics: // - If at least one user was added, return 201. When there are also conflicts, include them in the body. @@ -342,14 +235,7 @@ export class ProjectController { await this.assertProjectRolesNotManaged(); try { - await this.projectsService.changeUserRoleInProject(projectId, userId, body.role); - const relations = await this.projectsService.getProjectRelations(projectId); - this.eventService.emit('team-project-updated', { - userId: req.user.id, - role: req.user.role.slug, - members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })), - projectId, - }); + await this.projectsService.changeUserRoleInProject(req.user, projectId, userId, body.role); return res.status(204).send(); } catch (e) { if (e instanceof UnlicensedProjectRoleError) { @@ -368,14 +254,7 @@ export class ProjectController { @Param('userId') userId: string, ) { await this.assertProjectRolesNotManaged(); - await this.projectsService.deleteUserFromProject(projectId, userId); - const relations = await this.projectsService.getProjectRelations(projectId); - this.eventService.emit('team-project-updated', { - userId: req.user.id, - role: req.user.role.slug, - members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })), - projectId, - }); + await this.projectsService.deleteUserFromProject(req.user, projectId, userId); return res.status(204).send(); } @@ -390,13 +269,5 @@ export class ProjectController { await this.projectsService.deleteProject(req.user, projectId, { migrateToProject: query.transferId, }); - - this.eventService.emit('team-project-deleted', { - userId: req.user.id, - role: req.user.role.slug, - projectId, - removalType: query.transferId !== undefined ? 'transfer' : 'delete', - targetProjectId: query.transferId, - }); } } diff --git a/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts index 0709388a528..29ed10c7dd4 100644 --- a/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project-importer.test.ts @@ -54,6 +54,7 @@ describe('ProjectImporter.apply — custom span attributes', () => { await importer.apply(user, [item]); expect(projectService.updateProject).toHaveBeenCalledWith( + user, 'proj-1', expect.objectContaining({ customTelemetryTags: tags }), ); @@ -70,6 +71,7 @@ describe('ProjectImporter.apply — custom span attributes', () => { await importer.apply(user, [item]); expect(projectService.updateProject).toHaveBeenCalledWith( + user, 'proj-1', expect.objectContaining({ customTelemetryTags: undefined }), ); diff --git a/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts b/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts index c3aa1e9ac60..1988cd6b5b9 100644 --- a/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts @@ -114,7 +114,7 @@ export class ProjectImporter { } case 'update': - await this.projectService.updateProject(item.sourceProjectId, { + await this.projectService.updateProject(user, item.sourceProjectId, { name: item.name, icon: item.icon, description: item.description, 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 125e054556e..3b26bd0f6a1 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 @@ -6,18 +6,9 @@ import { UpdateProjectWithRelationsDto, } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; -import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import pick from 'lodash/pick'; -import { ProjectController } from '@/controllers/project.controller'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; -import type { PaginatedRequest } from '@/public-api/types'; -import { ProjectService } from '@/services/project.service.ee'; - import type { PublicAPIEndpoint } from '../../shared/handler.types'; import { apiKeyHasScopeWithGlobalScopeFallback, @@ -26,6 +17,13 @@ import { } from '../../shared/middlewares/global.middleware'; import { encodeNextCursor } from '../../shared/services/pagination.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; +import type { PaginatedRequest } from '@/public-api/types'; +import { ProjectService } from '@/services/project.service.ee'; + type GetAll = PaginatedRequest; type GetProjectUsersRequest = AuthenticatedRequest<{ projectId: string }> & GetAll; @@ -63,9 +61,13 @@ const projectHandlers: ProjectHandlers = { throw new BadRequestError(payload.error.errors[0].message); } - const project = await Container.get(ProjectController).createProject(req, res, payload.data); + const projectService = Container.get(ProjectService); - return res.status(201).json(project); + const project = await projectService.createTeamProject(req.user, payload.data); + + const scopes = await projectService.getProjectScopesForUser(req.user, project.id); + + return res.status(201).json({ ...project, role: 'project:admin', scopes }); }, ], updateProject: [ @@ -77,12 +79,8 @@ const projectHandlers: ProjectHandlers = { throw new BadRequestError(payload.error.errors[0].message); } - await Container.get(ProjectController).updateProject( - req, - res, - payload.data, - req.params.projectId, - ); + const { projectId } = req.params; + await Container.get(ProjectService).updateProject(req.user, projectId, payload.data); return res.status(204).send(); }, @@ -96,12 +94,11 @@ const projectHandlers: ProjectHandlers = { throw new BadRequestError(query.error.errors[0].message); } - await Container.get(ProjectController).deleteProject( - req, - res, - query.data, - req.params.projectId, - ); + const { projectId } = req.params; + const { transferId } = query.data; + await Container.get(ProjectService).deleteProject(req.user, projectId, { + migrateToProject: transferId, + }); return res.status(204).send(); }, @@ -113,9 +110,9 @@ const projectHandlers: ProjectHandlers = { async (req, res) => { const { offset = 0, limit = 100 } = req.query; - const [projects, count] = await Container.get(ProjectRepository).findAndCount({ - skip: offset, - take: limit, + const { projects, count } = await Container.get(ProjectService).getProjectsAndCount({ + offset, + limit, }); return res.json({ @@ -145,12 +142,9 @@ const projectHandlers: ProjectHandlers = { throw new NotFoundError(`Could not find project with ID "${projectId}"`); } - const projectRelationRepository = Container.get(ProjectRelationRepository); - const [relations, count] = await projectRelationRepository.findAndCount({ - where: { projectId }, - relations: { user: true, role: true }, - skip: offset, - take: limit, + const { members, count } = await projectService.getProjectMembersAndCount(projectId, { + offset, + limit, }); const memberFields = [ @@ -161,7 +155,7 @@ const projectHandlers: ProjectHandlers = { 'createdAt', 'updatedAt', ] as const; - const data = relations.map((relation) => ({ + const data = members.map((relation) => ({ ...pick(relation.user, memberFields), role: relation.role?.slug ?? null, })); @@ -188,6 +182,7 @@ const projectHandlers: ProjectHandlers = { } await Container.get(ProjectService).addUsersToProject( + req.user, req.params.projectId, payload.data.relations, ); @@ -208,7 +203,12 @@ const projectHandlers: ProjectHandlers = { const { projectId, userId } = req.params; const { role } = payload.data; - await Container.get(ProjectService).changeUserRoleInProject(projectId, userId, role); + await Container.get(ProjectService).changeUserRoleInProject( + req.user, + projectId, + userId, + role, + ); return res.status(204).send(); }, @@ -221,7 +221,7 @@ const projectHandlers: ProjectHandlers = { const { projectId, userId } = req.params; - await Container.get(ProjectService).deleteUserFromProject(projectId, userId); + await Container.get(ProjectService).deleteUserFromProject(req.user, projectId, userId); return res.status(204).send(); }, diff --git a/packages/cli/src/services/__tests__/project.service.ee.test.ts b/packages/cli/src/services/__tests__/project.service.ee.test.ts index a8d803fcbe9..05e5d373e90 100644 --- a/packages/cli/src/services/__tests__/project.service.ee.test.ts +++ b/packages/cli/src/services/__tests__/project.service.ee.test.ts @@ -7,6 +7,7 @@ import { type SharedWorkflowRepository, type ProjectRelationRepository, type SharedCredentials, + type User, ProjectRelation as ProjectRelationEntity, PROJECT_ADMIN_ROLE, PROJECT_VIEWER_ROLE, @@ -16,16 +17,18 @@ import type { EntityManager } from '@n8n/typeorm'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import type { OwnershipService } from '../ownership.service'; +import { ProjectService } from '../project.service.ee'; +import type { RoleService } from '../role.service'; + import type { ICredentialConnectionStatusProvider } from '@/credentials/credential-connection-status-provider.interface'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { EventService } from '@/events/event.service'; import type { AgentChatAttachmentService } from '@/modules/agents/agent-chat-attachment.service'; import type { AgentExecutionService } from '@/modules/agents/agent-execution.service'; import type { AgentKnowledgeService } from '@/modules/agents/agent-knowledge.service'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; - -import type { OwnershipService } from '../ownership.service'; -import { ProjectService } from '../project.service.ee'; -import type { RoleService } from '../role.service'; +import type { UserManagementMailer } from '@/user-management/email'; describe('ProjectService', () => { const manager = mock(); @@ -41,6 +44,9 @@ describe('ProjectService', () => { const agentChatAttachmentService = mock(); const ownershipService = mock(); const logger = mock(); + const eventService = mock(); + const userManagementMailer = mock(); + const user = mock({ id: 'actor-user', role: mock({ slug: 'global:owner' }) }); const projectService = new ProjectService( sharedWorkflowRepository, projectRepository, @@ -52,10 +58,13 @@ describe('ProjectService', () => { moduleRegistry, ownershipService, logger, + eventService, + userManagementMailer, ); beforeEach(() => { vi.clearAllMocks(); + projectRelationRepository.find.mockResolvedValue([]); }); describe('getAccessibleProjectsAndCount', () => { @@ -73,7 +82,7 @@ describe('ProjectService', () => { const result = await projectService.getAccessibleProjectsAndCount(adminUser, options); expect(projectRepository.findAllProjectsAndCount).toHaveBeenCalledWith(options); - expect(result).toEqual(expected); + expect(result).toEqual({ projects: expected[0], count: expected[1] }); }); it('should call getAccessibleProjectsAndCount for non-admin users', async () => { @@ -91,7 +100,7 @@ describe('ProjectService', () => { 'member-user', options, ); - expect(result).toEqual(expected); + expect(result).toEqual({ projects: expected[0], count: expected[1] }); }); }); @@ -106,7 +115,9 @@ describe('ProjectService', () => { // ACT & ASSERT await expect( - projectService.addUsersToProject(projectId, [{ userId: '1234', role: 'project:admin' }]), + projectService.addUsersToProject(user, projectId, [ + { userId: '1234', role: 'project:admin' }, + ]), ).rejects.toThrowError("Can't add users to personal projects."); }); @@ -120,11 +131,62 @@ describe('ProjectService', () => { // ACT & ASSERT await expect( - projectService.addUsersToProject(projectId, [ + projectService.addUsersToProject(user, projectId, [ { userId: '1234', role: PROJECT_OWNER_ROLE_SLUG }, ]), ).rejects.toThrowError("Can't add a personalOwner to a team project."); }); + + it('notifies only the newly added users, not existing members', async () => { + // ARRANGE + const projectId = '12345'; + projectRepository.findOne.mockResolvedValueOnce( + mock({ + id: projectId, + name: 'Team Project', + type: 'team', + // `existing` is already a member; `newcomer` is not. + projectRelations: [mock({ userId: 'existing' })], + }), + ); + roleService.isRoleLicensed.mockReturnValue(true); + + // ACT + await projectService.addUsersToProject(user, projectId, [ + { userId: 'existing', role: 'project:admin' }, + { userId: 'newcomer', role: 'project:viewer' }, + ]); + + // ASSERT: mailer called once, only for the newcomer + expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledTimes(1); + expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({ + sharer: user, + newSharees: [{ userId: 'newcomer', role: 'project:viewer' }], + project: { id: projectId, name: 'Team Project' }, + }); + }); + + it('does not notify when no users are new', async () => { + // ARRANGE + const projectId = '12345'; + projectRepository.findOne.mockResolvedValueOnce( + mock({ + id: projectId, + name: 'Team Project', + type: 'team', + projectRelations: [mock({ userId: 'existing' })], + }), + ); + roleService.isRoleLicensed.mockReturnValue(true); + + // ACT + await projectService.addUsersToProject(user, projectId, [ + { userId: 'existing', role: 'project:admin' }, + ]); + + // ASSERT + expect(userManagementMailer.notifyProjectShared).not.toHaveBeenCalled(); + }); }); describe('syncProjectRelations', () => { @@ -337,7 +399,7 @@ describe('ProjectService', () => { ); // ACT - await projectService.deleteUserFromProject(projectId, userId); + await projectService.deleteUserFromProject(user, projectId, userId); // ASSERT — member removed → cleanup must run inside the same transaction expect(mockProxy.cleanupOrphanedEntriesForUsers).toHaveBeenCalledWith([userId], manager); @@ -358,7 +420,7 @@ describe('ProjectService', () => { ); // ACT & ASSERT - await expect(projectService.deleteUserFromProject(projectId, ownerId)).rejects.toThrow( + await expect(projectService.deleteUserFromProject(user, projectId, ownerId)).rejects.toThrow( 'Project owner cannot be removed from the project', ); expect(mockProxy.cleanupOrphanedEntriesForUsers).not.toHaveBeenCalled(); @@ -374,7 +436,7 @@ describe('ProjectService', () => { it('should trim whitespace from tag keys on save', async () => { projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never); - await projectService.updateProject('proj-1', { + await projectService.updateProject(user, 'proj-1', { name: 'My Project', customTelemetryTags: [ { key: ' env ', value: 'production' }, @@ -396,7 +458,7 @@ describe('ProjectService', () => { it('should filter out tags with empty keys after trimming', async () => { projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never); - await projectService.updateProject('proj-1', { + await projectService.updateProject(user, 'proj-1', { name: 'My Project', customTelemetryTags: [ { key: ' ', value: 'ignored' }, @@ -415,7 +477,7 @@ describe('ProjectService', () => { it('should save undefined customTelemetryTags when not provided', async () => { projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never); - await projectService.updateProject('proj-1', { name: 'My Project' }); + await projectService.updateProject(user, 'proj-1', { name: 'My Project' }); expect(projectRepository.update).toHaveBeenCalledWith( { id: 'proj-1', type: 'team' }, @@ -426,7 +488,7 @@ describe('ProjectService', () => { it('should invalidate workflow project cache after a successful update', async () => { projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never); - await projectService.updateProject('proj-1', { name: 'Updated' }); + await projectService.updateProject(user, 'proj-1', { name: 'Updated' }); expect(ownershipService.invalidateWorkflowProjectCacheForProject).toHaveBeenCalledWith( 'proj-1', @@ -436,9 +498,50 @@ describe('ProjectService', () => { it('should throw NotFoundError when project is not found', async () => { projectRepository.update.mockResolvedValueOnce({ affected: 0 } as never); - await expect(projectService.updateProject('missing-proj', { name: 'Ghost' })).rejects.toThrow( - 'Could not find project with ID: missing-proj', - ); + await expect( + projectService.updateProject(user, 'missing-proj', { name: 'Ghost' }), + ).rejects.toThrow('Could not find project with ID: missing-proj'); + }); + + it('emits team-project-updated with the otel tag count when tags are provided', async () => { + projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never); + + await projectService.updateProject(user, 'proj-1', { + name: 'My Project', + customTelemetryTags: [ + { key: 'env', value: 'production' }, + { key: 'team', value: 'backend' }, + ], + }); + + expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { + userId: 'actor-user', + role: 'global:owner', + projectId: 'proj-1', + otelProjectCustomTagsCount: 2, + }); + }); + + it('emits team-project-updated without the otel tag count when tags are omitted', async () => { + projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never); + + await projectService.updateProject(user, 'proj-1', { name: 'My Project' }); + + expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { + userId: 'actor-user', + role: 'global:owner', + projectId: 'proj-1', + }); + }); + + it('does not emit when the project is not found', async () => { + projectRepository.update.mockResolvedValueOnce({ affected: 0 } as never); + + await expect( + projectService.updateProject(user, 'missing-proj', { name: 'Ghost' }), + ).rejects.toThrow(); + + expect(eventService.emit).not.toHaveBeenCalled(); }); }); @@ -474,8 +577,9 @@ describe('ProjectService', () => { }), ); roleService.isRoleLicensed.mockReturnValue(true); + projectRelationRepository.find.mockResolvedValue(mockRelations as never); - await projectService.changeUserRoleInProject(projectId, 'user2', 'project:admin'); + await projectService.changeUserRoleInProject(user, projectId, 'user2', 'project:admin'); expect(projectRepository.findOne).toHaveBeenCalledWith({ where: { id: projectId, type: 'team' }, @@ -488,6 +592,16 @@ describe('ProjectService', () => { { role: { slug: 'project:admin' } }, ); expect(mockProxy.cleanupOrphanedEntriesForUsers).toHaveBeenCalledWith(['user2'], manager); + + expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', { + userId: 'actor-user', + role: 'global:owner', + members: [ + { userId: 'user1', role: 'project:admin' }, + { userId: 'user2', role: 'project:viewer' }, + ], + projectId, + }); }); it('should throw if the user is not part of the project', async () => { @@ -501,7 +615,7 @@ describe('ProjectService', () => { roleService.isRoleLicensed.mockReturnValue(true); await expect( - projectService.changeUserRoleInProject(projectId, 'user3', 'project:admin'), + projectService.changeUserRoleInProject(user, projectId, 'user3', 'project:admin'), ).rejects.toThrow(`Could not find project with ID: ${projectId}`); expect(projectRepository.findOne).toHaveBeenCalledWith({ @@ -512,7 +626,7 @@ describe('ProjectService', () => { it('should throw if the role to be set is `project:personalOwner`', async () => { await expect( - projectService.changeUserRoleInProject(projectId, 'user2', PROJECT_OWNER_ROLE_SLUG), + projectService.changeUserRoleInProject(user, projectId, 'user2', PROJECT_OWNER_ROLE_SLUG), ).rejects.toThrow('Personal owner cannot be added to a team project.'); }); @@ -521,7 +635,7 @@ describe('ProjectService', () => { roleService.isRoleLicensed.mockReturnValue(true); await expect( - projectService.changeUserRoleInProject(projectId, 'user2', 'project:admin'), + projectService.changeUserRoleInProject(user, projectId, 'user2', 'project:admin'), ).rejects.toThrow(`Could not find project with ID: ${projectId}`); expect(projectRepository.findOne).toHaveBeenCalledWith({ @@ -532,7 +646,10 @@ describe('ProjectService', () => { }); describe('deleteProject', () => { - const user = { id: 'user-1', role: { scopes: [{ slug: 'project:delete' }] } } as any; + const user = { + id: 'user-1', + role: { slug: 'global:owner', scopes: [{ slug: 'project:delete' }] }, + } as any; beforeEach(() => { Object.defineProperty(projectService, 'workflowService', { @@ -576,6 +693,14 @@ describe('ProjectService', () => { expect(projectRepository.remove.mock.invocationCallOrder[0]).toBeLessThan( mockProxy.cleanupOrphanedEntriesForUsers.mock.invocationCallOrder[0], ); + + expect(eventService.emit).toHaveBeenCalledWith('team-project-deleted', { + userId: 'user-1', + role: 'global:owner', + projectId: project.id, + removalType: 'delete', + targetProjectId: undefined, + }); }); it('skips credential cleanup when the project had no members', async () => { @@ -666,6 +791,7 @@ describe('ProjectService', () => { const migratingUser = { id: 'user-1', role: { + slug: 'global:owner', scopes: [ { slug: 'project:delete' }, { slug: 'credential:create' }, @@ -739,6 +865,14 @@ describe('ProjectService', () => { 'team-2', ); expect(projectRepository.remove).toHaveBeenCalledWith(project); + + expect(eventService.emit).toHaveBeenCalledWith('team-project-deleted', { + userId: 'user-1', + role: 'global:owner', + projectId: project.id, + removalType: 'transfer', + targetProjectId: 'team-2', + }); }); }); diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 68b2e6e136e..d36c492cf75 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -18,6 +18,8 @@ import { getAuthPrincipalScopes, hasGlobalScope, type Scope, + type GlobalRole, + type ProjectRole, AssignableProjectRole, PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG, @@ -27,12 +29,14 @@ import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; import { UserError } from 'n8n-workflow'; +import { OwnershipService } from './ownership.service'; +import { RoleService } from './role.service'; + import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; - -import { OwnershipService } from './ownership.service'; -import { RoleService } from './role.service'; +import { EventService } from '@/events/event.service'; +import { UserManagementMailer } from '@/user-management/email'; export class TeamProjectOverQuotaError extends UserError { constructor(limit: number) { @@ -69,6 +73,11 @@ export interface ProjectCreateOverrides { customTelemetryTags?: Array<{ key: string; value: string }>; } +export type ProjectWithRelationsAndScopes = Project & { + role: ProjectRole | AssignableProjectRole | GlobalRole; + scopes?: Scope[]; +}; + @Service() export class ProjectService { constructor( @@ -82,6 +91,8 @@ export class ProjectService { private readonly moduleRegistry: ModuleRegistry, private readonly ownershipService: OwnershipService, private readonly logger: Logger, + private readonly eventService: EventService, + private readonly userManagementMailer: UserManagementMailer, ) {} private get workflowService() { @@ -305,6 +316,14 @@ export class ProjectService { const proxy = await this.connectionStatusProxy; await proxy.cleanupOrphanedEntriesForUsers(memberUserIds); } + + this.eventService.emit('team-project-deleted', { + userId: user.id, + role: user.role.slug, + projectId, + removalType: migrateToProject !== undefined ? 'transfer' : 'delete', + targetProjectId: migrateToProject, + }); } /** @@ -364,11 +383,11 @@ export class ProjectService { async getAccessibleProjectsAndCount( user: User, options: ProjectListOptions, - ): Promise<[Project[], number]> { - if (hasGlobalScope(user, 'project:read')) { - return await this.projectRepository.findAllProjectsAndCount(options); - } - return await this.projectRepository.getAccessibleProjectsAndCount(user.id, options); + ): Promise<{ projects: Project[]; count: number }> { + const [projects, count] = hasGlobalScope(user, 'project:read') + ? await this.projectRepository.findAllProjectsAndCount(options) + : await this.projectRepository.getAccessibleProjectsAndCount(user.id, options); + return { projects, count }; } // Returns the projects a caller can pick as share targets, including peer @@ -378,17 +397,88 @@ export class ProjectService { async getShareableProjectsAndCount( user: User, options: ProjectListOptions, - ): Promise<[Project[], number]> { - if (hasGlobalScope(user, 'project:read')) { - return await this.projectRepository.findAllProjectsAndCount(options); - } - return await this.projectRepository.getShareableProjectsAndCount(user.id, options); + ): Promise<{ projects: Project[]; count: number }> { + const [projects, count] = hasGlobalScope(user, 'project:read') + ? await this.projectRepository.findAllProjectsAndCount(options) + : await this.projectRepository.getShareableProjectsAndCount(user.id, options); + return { projects, count }; + } + + async getProjectsAndCount({ + offset, + limit, + }: { + offset: number; + limit: number; + }): Promise<{ projects: Project[]; count: number }> { + const [projects, count] = await this.projectRepository.findAndCount({ + skip: offset, + take: limit, + }); + return { projects, count }; } async getPersonalProjectOwners(projectIds: string[]): Promise { return await this.projectRelationRepository.getPersonalProjectOwners(projectIds); } + async getMyProjects(user: User): Promise { + const projectRelations = await this.getProjectRelationsForUser(user); + const otherTeamProjects = hasGlobalScope(user, 'project:read') + ? await this.projectRepository.findTeamProjectsExcluding( + projectRelations.map((relation) => relation.projectId), + ) + : []; + + const results: ProjectWithRelationsAndScopes[] = []; + + for (const relation of projectRelations) { + const result: ProjectWithRelationsAndScopes = Object.assign( + this.projectRepository.create(relation.project), + { role: relation.role.slug, scopes: [] }, + ); + + if (result.scopes) { + result.scopes.push( + ...combineScopes({ + global: getAuthPrincipalScopes(user), + project: relation.role.scopes.map((scope) => scope.slug), + }), + ); + } + + results.push(result); + } + + for (const project of otherTeamProjects) { + const result: ProjectWithRelationsAndScopes = Object.assign( + this.projectRepository.create(project), + { + // If the user has the global `project:read` scope then they may not + // own this relationship in that case we use the global user role + // instead of the relation role, which is for another user. + role: user.role.slug, + scopes: [], + }, + ); + + if (result.scopes) { + result.scopes.push(...combineScopes({ global: getAuthPrincipalScopes(user) })); + } + + results.push(result); + } + + // Deduplicate and sort scopes + for (const result of results) { + if (result.scopes) { + result.scopes = [...new Set(result.scopes)].sort(); + } + } + + return results; + } + private async createTeamProjectWithEntityManager( adminUser: User, data: CreateProjectDto, @@ -426,12 +516,24 @@ export class ProjectService { ): Promise { // This needs to be SERIALIZABLE otherwise the count would not block a // concurrent transaction and we could insert multiple projects. - return await this.projectRepository.manager.transaction('SERIALIZABLE', async (trx) => { - return await this.createTeamProjectWithEntityManager(adminUser, data, trx, overrides); + const project = await this.projectRepository.manager.transaction( + 'SERIALIZABLE', + async (trx) => { + return await this.createTeamProjectWithEntityManager(adminUser, data, trx, overrides); + }, + ); + + this.eventService.emit('team-project-created', { + userId: adminUser.id, + role: adminUser.role.slug, + uiContext: data.uiContext, }); + + return project; } async updateProject( + user: User, projectId: string, { name, icon, description, customTelemetryTags }: UpdateProjectDto, ): Promise { @@ -449,6 +551,15 @@ export class ProjectService { // Ensure OTel spans pick up the updated customTelemetryTags on the next execution. await this.ownershipService.invalidateWorkflowProjectCacheForProject(projectId); + + this.eventService.emit('team-project-updated', { + userId: user.id, + role: user.role.slug, + projectId, + ...(customTelemetryTags !== undefined + ? { otelProjectCustomTagsCount: customTelemetryTags.length } + : {}), + }); } async getPersonalProject(user: User): Promise { @@ -513,6 +624,29 @@ export class ProjectService { return { project, newRelations }; } + private async notifyNewSharees( + sharer: User, + project: Project, + newSharees: Array<{ userId: string; role: AssignableProjectRole }>, + ) { + if (newSharees.length === 0) return; + await this.userManagementMailer.notifyProjectShared({ + sharer, + newSharees, + project: { id: project.id, name: project.name }, + }); + } + + private async emitProjectMembersUpdated(user: User, projectId: string) { + const relations = await this.getProjectRelations(projectId); + this.eventService.emit('team-project-updated', { + userId: user.id, + role: user.role.slug, + members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })), + projectId, + }); + } + /** * Adds users to a team project with specified roles. * @@ -520,6 +654,7 @@ export class ProjectService { * Throws if the relations contain `project:personalOwner`. */ async addUsersToProject( + user: User, projectId: string, relations: Array<{ userId: string; role: AssignableProjectRole }>, ) { @@ -540,6 +675,9 @@ export class ProjectService { throw new ForbiddenError("Can't add a personalOwner to a team project."); } + const existingUserIds = new Set(project.projectRelations.map((pr) => pr.userId)); + const newSharees = relations.filter((relation) => !existingUserIds.has(relation.userId)); + await this.projectRelationRepository.save( relations.map((relation) => ({ projectId, @@ -547,6 +685,9 @@ export class ProjectService { role: { slug: relation.role }, })), ); + + await this.notifyNewSharees(user, project, newSharees); + await this.emitProjectMembersUpdated(user, projectId); } /** @@ -556,6 +697,7 @@ export class ProjectService { * - Reports conflicts for users already in the project with a different role (no change) */ async addUsersWithConflictSemantics( + user: User, projectId: string, relations: Array<{ userId: string; role: AssignableProjectRole }>, ): Promise<{ @@ -607,6 +749,9 @@ export class ProjectService { added.push(...toInsert); } + await this.notifyNewSharees(user, project, added); + await this.emitProjectMembersUpdated(user, project.id); + return { project, added, conflicts }; } @@ -640,7 +785,7 @@ export class ProjectService { ); } - async deleteUserFromProject(projectId: string, userId: string) { + async deleteUserFromProject(user: User, projectId: string, userId: string) { const project = await this.getTeamProjectWithRelations(projectId); // Prevent project owner from being removed @@ -654,9 +799,16 @@ export class ProjectService { await em.delete(ProjectRelation, { projectId: project.id, userId }); await proxy.cleanupOrphanedEntriesForUsers([userId], em); }); + + await this.emitProjectMembersUpdated(user, projectId); } - async changeUserRoleInProject(projectId: string, userId: string, role: AssignableProjectRole) { + async changeUserRoleInProject( + user: User, + projectId: string, + userId: string, + role: AssignableProjectRole, + ) { if (role === PROJECT_OWNER_ROLE_SLUG) { throw new ForbiddenError('Personal owner cannot be added to a team project.'); } @@ -686,6 +838,8 @@ export class ProjectService { await em.update(ProjectRelation, { projectId, userId }, { role: { slug: role } }); await proxy.cleanupOrphanedEntriesForUsers([userId], em); }); + + await this.emitProjectMembersUpdated(user, projectId); } async pruneRelations(em: EntityManager, project: Project) { @@ -850,6 +1004,29 @@ export class ProjectService { }); } + async getProjectMembersAndCount( + projectId: string, + { offset, limit }: { offset: number; limit: number }, + ): Promise<{ members: ProjectRelation[]; count: number }> { + const [members, count] = await this.projectRelationRepository.findAndCount({ + where: { projectId }, + relations: { user: true, role: true }, + skip: offset, + take: limit, + }); + return { members, count }; + } + + async getProjectScopesForUser(user: User, projectId: string): Promise { + const relation = await this.getProjectRelationForUserAndProject(user.id, projectId); + return [ + ...combineScopes({ + global: getAuthPrincipalScopes(user), + project: relation?.role.scopes.map((scope) => scope.slug) ?? [], + }), + ]; + } + async getUserOwnedOrAdminProjects(userId: string): Promise { return await this.projectRepository.find({ where: { diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts index 685cb9ccdec..3e5c0017531 100644 --- a/packages/cli/test/integration/project.service.integration.test.ts +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -8,13 +8,13 @@ import { } from '@n8n/backend-test-utils'; import { SharedWorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; - -import { License } from '@/license'; -import { ProjectService } from '@/services/project.service.ee'; import { LicenseMocker } from '@test-integration/license'; import { createUser } from './shared/db/users'; +import { License } from '@/license'; +import { ProjectService } from '@/services/project.service.ee'; + describe('ProjectService', () => { let projectService: ProjectService; let sharedWorkflowRepository: SharedWorkflowRepository; @@ -53,7 +53,7 @@ describe('ProjectService', () => { // ACT // add user again - await projectService.addUsersToProject(project.id, [ + await projectService.addUsersToProject(user, project.id, [ { userId: user.id, role: 'project:admin' }, ]); @@ -74,7 +74,7 @@ describe('ProjectService', () => { // ACT // add user again - await projectService.addUsersToProject(project.id, [ + await projectService.addUsersToProject(user, project.id, [ { userId: user.id, role: 'project:editor' }, ]); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index 63c9b0f4bed..f17c55fd187 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -11,6 +11,7 @@ import { Container } from '@n8n/di'; import type { MockInstance } from 'vitest'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; +import { EventService } from '@/events/event.service'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { Telemetry } from '@/telemetry'; import { @@ -24,12 +25,19 @@ describe('Projects in Public API', () => { const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); mockInstance(Telemetry); + let emitSpy: MockInstance; + beforeAll(async () => { await testDb.init(); }); beforeEach(async () => { await testDb.truncate(['Project', 'User']); + emitSpy = vi.spyOn(Container.get(EventService), 'emit'); + }); + + afterEach(() => { + emitSpy.mockRestore(); }); describe('GET /projects', () => { @@ -156,6 +164,11 @@ describe('Projects in Public API', () => { scopes: expect.any(Array), }); await expect(getProjectByNameOrFail(projectPayload.name)).resolves.not.toThrow(); + expect(emitSpy).toHaveBeenCalledWith('team-project-created', { + userId: owner.id, + role: owner.role.slug, + uiContext: undefined, + }); }); it('if not authenticated, should reject', async () => { @@ -249,6 +262,13 @@ describe('Projects in Public API', () => { */ expect(response.status).toBe(204); await expect(getProjectByNameOrFail(project.id)).rejects.toThrow(); + expect(emitSpy).toHaveBeenCalledWith('team-project-deleted', { + userId: owner.id, + role: owner.role.slug, + projectId: project.id, + removalType: 'delete', + targetProjectId: undefined, + }); }); it('if not authenticated, should reject', async () => { @@ -338,6 +358,11 @@ describe('Projects in Public API', () => { */ expect(response.status).toBe(204); await expect(getProjectByNameOrFail('new-name')).resolves.not.toThrow(); + expect(emitSpy).toHaveBeenCalledWith('team-project-updated', { + userId: owner.id, + role: owner.role.slug, + projectId: project.id, + }); }); it('if not authenticated, should reject', async () => { diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts index 74b42d206d2..46736ab8252 100644 --- a/packages/cli/test/integration/services/project.service.test.ts +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -3,14 +3,14 @@ import { testDb } from '@n8n/backend-test-utils'; import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions'; - -import { License } from '@/license'; -import { ProjectService } from '@/services/project.service.ee'; import { createRole } from '@test-integration/db/roles'; import { LicenseMocker } from '@test-integration/license'; import { createMember } from '../shared/db/users'; +import { License } from '@/license'; +import { ProjectService } from '@/services/project.service.ee'; + let projectRepository: ProjectRepository; let projectService: ProjectService; let projectRelationRepository: ProjectRelationRepository; @@ -254,9 +254,9 @@ describe('ProjectService', () => { ); await projectService.addUser(project.id, { userId: user.id, role }); - await expect(projectService.deleteUserFromProject(project.id, user.id)).rejects.toThrowError( - /^Project owner cannot be removed from the project$/, - ); + await expect( + projectService.deleteUserFromProject(user, project.id, user.id), + ).rejects.toThrowError(/^Project owner cannot be removed from the project$/); }); it('should remove user from project if not owner', async () => { @@ -271,7 +271,7 @@ describe('ProjectService', () => { ); await projectService.addUser(project.id, { userId: user.id, role }); - await projectService.deleteUserFromProject(project.id, user.id); + await projectService.deleteUserFromProject(user, project.id, user.id); const relations = await projectRelationRepository.findOne({ where: { userId: user.id, projectId: project.id, role: { slug: role } }, @@ -298,6 +298,7 @@ describe('ProjectService', () => { // ACT // await projectService.addUsersToProject( + members[0], project.id, members.map((member) => ({ userId: member.id, role: 'project:editor' })), ); @@ -328,7 +329,7 @@ describe('ProjectService', () => { // ACT // await expect( - projectService.addUsersToProject(project.id, [ + projectService.addUsersToProject(member, project.id, [ { userId: member.id, role: 'custom:non-existing' }, ]), ).rejects.toThrowError('Role custom:non-existing does not exist'); @@ -404,7 +405,7 @@ describe('ProjectService', () => { // ACT // await projectService.addUser(project.id, { userId: user.id, role: 'project:viewer' }); - await projectService.changeUserRoleInProject(project.id, user.id, 'project:editor'); + await projectService.changeUserRoleInProject(user, project.id, user.id, 'project:editor'); // // ASSERT @@ -434,7 +435,7 @@ describe('ProjectService', () => { // await projectService.addUser(project.id, { userId: user.id, role: 'project:viewer' }); await expect( - projectService.changeUserRoleInProject(project.id, user.id, 'project:non-existing'), + projectService.changeUserRoleInProject(user, project.id, user.id, 'project:non-existing'), ).rejects.toThrowError('Role project:non-existing does not exist'); }); });