refactor(API): De-Express projects endpoints and drop direct-repo reads (no-changelog) (#37123)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Verify single-instance deps (npm-install) (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Dmitrii 2026-08-28 07:53:17 +00:00 committed by GitHub
parent 614dfa62f8
commit 662a64de83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 480 additions and 361 deletions

View File

@ -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<Project> {
});
}
async findTeamProjectsExcluding(excludedProjectIds: string[]): Promise<Project[]> {
return await this.findBy({ type: 'team', id: Not(In(excludedProjectIds)) });
}
async getAccessibleProjectsByExactName(
userId: string,
name: string,

View File

@ -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<EventService>();
const projectsService = mock<ProjectService>();
const projectRepository = mock<ProjectRepository>();
const userManagementMailer = mock<UserManagementMailer>();
const provisioningService = mock<ProvisioningService>();
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) => {

View File

@ -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<ProjectRequest.GetMyProjectsResponse> {
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,
});
}
}

View File

@ -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 }),
);

View File

@ -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,

View File

@ -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();
},

View File

@ -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<EntityManager>();
@ -41,6 +44,9 @@ describe('ProjectService', () => {
const agentChatAttachmentService = mock<AgentChatAttachmentService>();
const ownershipService = mock<OwnershipService>();
const logger = mock<Logger>();
const eventService = mock<EventService>();
const userManagementMailer = mock<UserManagementMailer>();
const user = mock<User>({ 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<Project>({
id: projectId,
name: 'Team Project',
type: 'team',
// `existing` is already a member; `newcomer` is not.
projectRelations: [mock<ProjectRelationEntity>({ 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<Project>({
id: projectId,
name: 'Team Project',
type: 'team',
projectRelations: [mock<ProjectRelationEntity>({ 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',
});
});
});

View File

@ -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<ProjectRelation[]> {
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
}
async getMyProjects(user: User): Promise<ProjectWithRelationsAndScopes[]> {
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<Project> {
// 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<void> {
@ -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<Project | null> {
@ -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<Scope[]> {
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<Project[]> {
return await this.projectRepository.find({
where: {

View File

@ -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' },
]);

View File

@ -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<EventService['emit']>;
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 () => {

View File

@ -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');
});
});