diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index 6801b69ca85..ab9157cf6d4 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -59,6 +59,9 @@ export class AgentsModule implements ModuleInterface { agentKnowledgeFileStore: Container.get(AgentKnowledgeFileStore), }); + const { registerFavoriteResolver } = await import('./register-favorite-resolver.js'); + registerFavoriteResolver(); + const { AgentRuntimeCacheService } = await import('./agent-runtime-cache.service.js'); Container.get(AgentRuntimeCacheService); diff --git a/packages/cli/src/modules/agents/register-favorite-resolver.ts b/packages/cli/src/modules/agents/register-favorite-resolver.ts new file mode 100644 index 00000000000..f17f248b7fc --- /dev/null +++ b/packages/cli/src/modules/agents/register-favorite-resolver.ts @@ -0,0 +1,20 @@ +import { Container } from '@n8n/di'; + +import { FavoriteResourceResolverRegistry } from '@/modules/favorites/favorite-resource-resolver.registry'; + +import { AgentRepository } from './repositories/agent.repository'; + +export function registerFavoriteResolver() { + const agentRepository = Container.get(AgentRepository); + Container.get(FavoriteResourceResolverRegistry).register('agent', { + globalReadScope: 'agent:read', + findMeta: async (ids) => + new Map( + (await agentRepository.findSummariesByIds(ids)).map(({ id, name, projectId }) => [ + id, + { name, projectId }, + ]), + ), + exists: async (id) => await agentRepository.existsBy({ id }), + }); +} diff --git a/packages/cli/src/modules/data-table/data-table.module.ts b/packages/cli/src/modules/data-table/data-table.module.ts index 41159f43127..e9ae8eb8a64 100644 --- a/packages/cli/src/modules/data-table/data-table.module.ts +++ b/packages/cli/src/modules/data-table/data-table.module.ts @@ -29,6 +29,9 @@ export class DataTableModule implements ModuleInterface { }, }); + const { registerFavoriteResolver } = await import('./register-favorite-resolver.js'); + registerFavoriteResolver(); + const { DataTableAggregateService } = await import('./data-table-aggregate.service.js'); await Container.get(DataTableAggregateService).start(); diff --git a/packages/cli/src/modules/data-table/data-table.repository.ts b/packages/cli/src/modules/data-table/data-table.repository.ts index f0b7c7dcd01..3ea6e111db9 100644 --- a/packages/cli/src/modules/data-table/data-table.repository.ts +++ b/packages/cli/src/modules/data-table/data-table.repository.ts @@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Project, withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, EntityManager, Repository, SelectQueryBuilder } from '@n8n/typeorm'; +import { DataSource, EntityManager, In, Repository, SelectQueryBuilder } from '@n8n/typeorm'; import { DATA_TABLE_SYSTEM_COLUMNS, DATA_TABLE_SYSTEM_TESTING_COLUMN, @@ -191,6 +191,17 @@ export class DataTableRepository extends Repository { }); } + async findSummariesByIds( + ids: string[], + ): Promise>> { + if (ids.length === 0) return []; + + return await this.find({ + select: ['id', 'name', 'projectId'], + where: { id: In(ids) }, + }); + } + async getManyAndCount(options: Partial) { const query = this.getManyQuery(options); const [data, count] = await query.getManyAndCount(); diff --git a/packages/cli/src/modules/data-table/register-favorite-resolver.ts b/packages/cli/src/modules/data-table/register-favorite-resolver.ts new file mode 100644 index 00000000000..23f292f6f40 --- /dev/null +++ b/packages/cli/src/modules/data-table/register-favorite-resolver.ts @@ -0,0 +1,20 @@ +import { Container } from '@n8n/di'; + +import { FavoriteResourceResolverRegistry } from '@/modules/favorites/favorite-resource-resolver.registry'; + +import { DataTableRepository } from './data-table.repository'; + +export function registerFavoriteResolver() { + const dataTableRepository = Container.get(DataTableRepository); + Container.get(FavoriteResourceResolverRegistry).register('dataTable', { + globalReadScope: 'dataTable:read', + findMeta: async (ids) => + new Map( + (await dataTableRepository.findSummariesByIds(ids)).map(({ id, name, projectId }) => [ + id, + { name, projectId }, + ]), + ), + exists: async (id) => await dataTableRepository.existsBy({ id }), + }); +} diff --git a/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts b/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts index 56e9719beda..ca39a15eb2a 100644 --- a/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts +++ b/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts @@ -11,11 +11,13 @@ import { mock } from 'vitest-mock-extended'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { DataTableRepository } from '@/modules/data-table/data-table.repository'; -import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import type { UserFavorite } from '../database/entities/user-favorite.entity'; import type { UserFavoriteRepository } from '../database/repositories/user-favorite.repository'; +import { + FavoriteResourceResolverRegistry, + type FavoriteResourceResolver, +} from '../favorite-resource-resolver.registry'; import { FavoritesService } from '../favorites.service'; const makeUserFavorite = (overrides: Partial = {}): UserFavorite => @@ -46,17 +48,27 @@ describe('FavoritesService', () => { const workflowRepository = mock(); const projectRepository = mock(); const sharedWorkflowRepository = mock(); - const dataTableRepository = mock(); const folderRepository = mock(); - const agentRepository = mock(); + const dataTableResolver = { + globalReadScope: 'dataTable:read', + findMeta: vi.fn(), + exists: vi.fn(), + } satisfies FavoriteResourceResolver; + const agentResolver = { + globalReadScope: 'agent:read', + findMeta: vi.fn(), + exists: vi.fn(), + } satisfies FavoriteResourceResolver; + const resolverRegistry = new FavoriteResourceResolverRegistry(); + resolverRegistry.register('dataTable', dataTableResolver); + resolverRegistry.register('agent', agentResolver); const service = new FavoritesService( repo, workflowRepository, projectRepository, sharedWorkflowRepository, - dataTableRepository, folderRepository, - agentRepository, + resolverRegistry, ); afterEach(() => vi.clearAllMocks()); @@ -143,12 +155,13 @@ describe('FavoritesService', () => { projectRepository.getAccessibleProjects.mockResolvedValue([ { id: 'proj1', name: 'Project 1' } as never, ]); - dataTableRepository.find.mockResolvedValue([ - { id: 'dt1', name: 'My Table', projectId: 'proj1' } as never, - ]); + dataTableResolver.findMeta.mockResolvedValue( + new Map([['dt1', { name: 'My Table', projectId: 'proj1' }]]), + ); const result = await service.getEnrichedFavorites(makeUser()); + expect(dataTableResolver.findMeta).toHaveBeenCalledWith(['dt1']); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ resourceId: 'dt1', @@ -163,15 +176,35 @@ describe('FavoritesService', () => { projectRepository.getAccessibleProjects.mockResolvedValue([ { id: 'proj1', name: 'Project 1' } as never, ]); - dataTableRepository.find.mockResolvedValue([ - { id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never, - ]); + dataTableResolver.findMeta.mockResolvedValue( + new Map([['dt1', { name: 'My Table', projectId: 'proj-other' }]]), + ); const result = await service.getEnrichedFavorites(makeUser()); expect(result).toHaveLength(0); }); + it('should exclude favorites of a type that has no registered resolver', async () => { + const favorite = makeUserFavorite({ resourceId: 'dt1', resourceType: 'dataTable' }); + repo.findByUser.mockResolvedValue([favorite]); + projectRepository.getAccessibleProjects.mockResolvedValue([ + { id: 'proj1', name: 'Project 1' } as never, + ]); + const serviceWithoutResolvers = new FavoritesService( + repo, + workflowRepository, + projectRepository, + sharedWorkflowRepository, + folderRepository, + new FavoriteResourceResolverRegistry(), + ); + + const result = await serviceWithoutResolvers.getEnrichedFavorites(makeUser()); + + expect(result).toHaveLength(0); + }); + it('should enrich folder favorites with name and projectId for accessible folders', async () => { const favorite = makeUserFavorite({ resourceId: 'folder1', resourceType: 'folder' }); repo.findByUser.mockResolvedValue([favorite]); @@ -240,12 +273,13 @@ describe('FavoritesService', () => { projectRepository.getAccessibleProjects.mockResolvedValue([ { id: 'proj1', name: 'Project 1' } as never, ]); - agentRepository.find.mockResolvedValue([ - { id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never, - ]); + agentResolver.findMeta.mockResolvedValue( + new Map([['agent1', { name: 'My Agent', projectId: 'proj1' }]]), + ); const result = await service.getEnrichedFavorites(makeUser()); + expect(agentResolver.findMeta).toHaveBeenCalledWith(['agent1']); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ resourceId: 'agent1', @@ -260,9 +294,9 @@ describe('FavoritesService', () => { projectRepository.getAccessibleProjects.mockResolvedValue([ { id: 'proj1', name: 'Project 1' } as never, ]); - agentRepository.find.mockResolvedValue([ - { id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never, - ]); + agentResolver.findMeta.mockResolvedValue( + new Map([['agent1', { name: 'My Agent', projectId: 'proj-other' }]]), + ); const result = await service.getEnrichedFavorites(makeUser()); @@ -282,15 +316,15 @@ describe('FavoritesService', () => { ]); workflowRepository.findByIds.mockResolvedValue([{ id: 'wf1', name: 'My Workflow' } as never]); sharedWorkflowRepository.find.mockResolvedValue([{ workflowId: 'wf1' } as never]); - dataTableRepository.find.mockResolvedValue([ - { id: 'dt1', name: 'My Table', projectId: 'proj1' } as never, - ]); + dataTableResolver.findMeta.mockResolvedValue( + new Map([['dt1', { name: 'My Table', projectId: 'proj1' }]]), + ); folderRepository.find.mockResolvedValue([ { id: 'folder1', name: 'My Folder', homeProject: { id: 'proj1' } } as never, ]); - agentRepository.find.mockResolvedValue([ - { id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never, - ]); + agentResolver.findMeta.mockResolvedValue( + new Map([['agent1', { name: 'My Agent', projectId: 'proj1' }]]), + ); const result = await service.getEnrichedFavorites(makeUser()); @@ -334,9 +368,9 @@ describe('FavoritesService', () => { const favorite = makeUserFavorite({ resourceId: 'dt1', resourceType: 'dataTable' }); repo.findByUser.mockResolvedValue([favorite]); projectRepository.getAccessibleProjects.mockResolvedValue([]); - dataTableRepository.find.mockResolvedValue([ - { id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never, - ]); + dataTableResolver.findMeta.mockResolvedValue( + new Map([['dt1', { name: 'My Table', projectId: 'proj-other' }]]), + ); const result = await service.getEnrichedFavorites(makeAdmin()); @@ -374,9 +408,9 @@ describe('FavoritesService', () => { const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' }); repo.findByUser.mockResolvedValue([favorite]); projectRepository.getAccessibleProjects.mockResolvedValue([]); - agentRepository.find.mockResolvedValue([ - { id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never, - ]); + agentResolver.findMeta.mockResolvedValue( + new Map([['agent1', { name: 'My Agent', projectId: 'proj-other' }]]), + ); const result = await service.getEnrichedFavorites(makeAdmin()); @@ -475,18 +509,14 @@ describe('FavoritesService', () => { it.each([ { resourceType: 'workflow' as const, repository: 'workflowRepository' }, { resourceType: 'project' as const, repository: 'projectRepository' }, - { resourceType: 'dataTable' as const, repository: 'dataTableRepository' }, { resourceType: 'folder' as const, repository: 'folderRepository' }, - { resourceType: 'agent' as const, repository: 'agentRepository' }, ])( 'should check existence using the correct repository for $resourceType', async ({ resourceType, repository }) => { const repositories = { workflowRepository, projectRepository, - dataTableRepository, folderRepository, - agentRepository, }; repo.findOne.mockResolvedValue(null); repositories[repository as keyof typeof repositories].existsBy.mockResolvedValue(false); @@ -499,6 +529,65 @@ describe('FavoritesService', () => { ); }, ); + + it.each([ + { resourceType: 'dataTable' as const, resolver: dataTableResolver }, + { resourceType: 'agent' as const, resolver: agentResolver }, + ])( + 'should check existence using the registered resolver for $resourceType', + async ({ resourceType, resolver }) => { + repo.findOne.mockResolvedValue(null); + resolver.exists.mockResolvedValue(false); + + await expect(service.addFavorite('user1', 'res1', resourceType)).rejects.toThrow( + NotFoundError, + ); + expect(resolver.exists).toHaveBeenCalledWith('res1'); + }, + ); + + it.each([ + { resourceType: 'dataTable' as const, resolver: dataTableResolver }, + { resourceType: 'agent' as const, resolver: agentResolver }, + ])( + 'should create the favorite when the resolver reports the $resourceType exists', + async ({ resourceType, resolver }) => { + const favorite = mock(); + repo.findOne.mockResolvedValue(null); + resolver.exists.mockResolvedValue(true); + repo.count.mockResolvedValue(0); + repo.create.mockReturnValue(favorite); + repo.save.mockResolvedValue(favorite); + + const result = await service.addFavorite('user1', 'res1', resourceType); + + expect(resolver.exists).toHaveBeenCalledWith('res1'); + expect(repo.create).toHaveBeenCalledWith({ + userId: 'user1', + resourceId: 'res1', + resourceType, + }); + expect(repo.save).toHaveBeenCalledWith(favorite); + expect(result).toBe(favorite); + }, + ); + + it('should throw NotFoundError when the resource type has no registered resolver', async () => { + const serviceWithoutResolvers = new FavoritesService( + repo, + workflowRepository, + projectRepository, + sharedWorkflowRepository, + folderRepository, + new FavoriteResourceResolverRegistry(), + ); + repo.findOne.mockResolvedValue(null); + + await expect(serviceWithoutResolvers.addFavorite('user1', 'res1', 'agent')).rejects.toThrow( + NotFoundError, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); }); describe('removeFavorite', () => { diff --git a/packages/cli/src/modules/favorites/favorite-resource-resolver.registry.ts b/packages/cli/src/modules/favorites/favorite-resource-resolver.registry.ts new file mode 100644 index 00000000000..0e2830052a0 --- /dev/null +++ b/packages/cli/src/modules/favorites/favorite-resource-resolver.registry.ts @@ -0,0 +1,35 @@ +import type { FavoriteResourceType } from '@n8n/api-types'; +import { Service } from '@n8n/di'; +import type { Scope } from '@n8n/permissions'; + +export type FavoriteResourceMeta = { name: string; projectId: string }; + +/** Favorite types resolved via a registered resolver; all others are handled by the favorites module itself. */ +export type ResolvedFavoriteResourceType = Extract; + +/** + * Resolves favorited resources owned by another backend module. The owning + * module registers its resolver in its `init()`, so favorites never depends + * on module internals; if the module is inactive, no resolver exists and + * favorites of that type are treated as missing resources. + */ +export interface FavoriteResourceResolver { + /** Scope granting access to the resource regardless of project membership. */ + readonly globalReadScope: Scope; + /** Per-id metadata; ids missing from the map are treated as deleted or inaccessible. */ + findMeta(ids: string[]): Promise>; + exists(id: string): Promise; +} + +@Service() +export class FavoriteResourceResolverRegistry { + private readonly resolvers = new Map(); + + register(type: ResolvedFavoriteResourceType, resolver: FavoriteResourceResolver) { + this.resolvers.set(type, resolver); + } + + get(type: ResolvedFavoriteResourceType): FavoriteResourceResolver | undefined { + return this.resolvers.get(type); + } +} diff --git a/packages/cli/src/modules/favorites/favorites.service.ts b/packages/cli/src/modules/favorites/favorites.service.ts index 0e0c9b71025..a7de5200a80 100644 --- a/packages/cli/src/modules/favorites/favorites.service.ts +++ b/packages/cli/src/modules/favorites/favorites.service.ts @@ -13,13 +13,13 @@ import { In } from '@n8n/typeorm'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { DataTableRepository } from '@/modules/data-table/data-table.repository'; import { UserFavoriteRepository } from './database/repositories/user-favorite.repository'; - -import { AgentRepository } from '../agents/repositories/agent.repository'; - -type ResourceMeta = { name: string; projectId: string }; +import type { + FavoriteResourceMeta as ResourceMeta, + ResolvedFavoriteResourceType, +} from './favorite-resource-resolver.registry'; +import { FavoriteResourceResolverRegistry } from './favorite-resource-resolver.registry'; type Favorite = { resourceId: string; resourceType: FavoriteResourceType }; @@ -48,9 +48,8 @@ export class FavoritesService { private readonly workflowRepository: WorkflowRepository, private readonly projectRepository: ProjectRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, - private readonly dataTableRepository: DataTableRepository, private readonly folderRepository: FolderRepository, - private readonly agentRepository: AgentRepository, + private readonly resourceResolvers: FavoriteResourceResolverRegistry, ) {} async getEnrichedFavorites(user: User) { @@ -71,9 +70,9 @@ export class FavoritesService { const [workflowNames, projectNames, dataTableMeta, folderMeta, agentMeta] = await Promise.all([ this.enrichWorkflowFavorites(user, ids.workflow, accessibleProjectIds), this.enrichProjectFavorites(user, ids.project, accessibleProjects), - this.enrichDataTableFavorites(user, ids.dataTable, accessibleProjectIds), + this.enrichResolvedFavorites(user, 'dataTable', ids.dataTable, accessibleProjectIds), this.enrichFolderFavorites(user, ids.folder, accessibleProjectIds), - this.enrichAgentFavorites(user, ids.agent, accessibleProjectIds), + this.enrichResolvedFavorites(user, 'agent', ids.agent, accessibleProjectIds), ]); return favorites.flatMap((fav) => { @@ -148,20 +147,26 @@ export class FavoritesService { return result; } - private async enrichDataTableFavorites( + /** Enriches favorites whose resource type is owned by another module, via its registered resolver. */ + private async enrichResolvedFavorites( user: User, - dataTableIds: string[], + resourceType: ResolvedFavoriteResourceType, + resourceIds: string[], accessibleProjectIds: Set, ): Promise> { const result = new Map(); - if (dataTableIds.length === 0) return result; + if (resourceIds.length === 0) return result; - const hasGlobalAccess = hasGlobalScope(user, 'dataTable:read'); - const dataTables = await this.dataTableRepository.find({ where: { id: In(dataTableIds) } }); + // No resolver means the owning module is inactive: treat its favorites as missing resources + const resolver = this.resourceResolvers.get(resourceType); + if (!resolver) return result; - for (const dt of dataTables) { - if (hasGlobalAccess || accessibleProjectIds.has(dt.projectId)) { - result.set(dt.id, { name: dt.name, projectId: dt.projectId }); + const hasGlobalAccess = hasGlobalScope(user, resolver.globalReadScope); + const meta = await resolver.findMeta(resourceIds); + + for (const [id, resourceMeta] of meta) { + if (hasGlobalAccess || accessibleProjectIds.has(resourceMeta.projectId)) { + result.set(id, resourceMeta); } } return result; @@ -190,27 +195,6 @@ export class FavoritesService { return result; } - private async enrichAgentFavorites( - user: User, - agentIds: string[], - accessibleProjectIds: Set, - ): Promise> { - const result = new Map(); - if (agentIds.length === 0) return result; - - const hasGlobalAccess = hasGlobalScope(user, 'agent:read'); - const agents = await this.agentRepository.find({ - where: { id: In(agentIds) }, - }); - - for (const agent of agents) { - if (hasGlobalAccess || accessibleProjectIds.has(agent.projectId)) { - result.set(agent.id, { name: agent.name, projectId: agent.projectId }); - } - } - return result; - } - async addFavorite(userId: string, resourceId: string, resourceType: FavoriteResourceType) { const existing = await this.userFavoriteRepository.findOne({ where: { userId, resourceId, resourceType }, @@ -242,14 +226,12 @@ export class FavoritesService { case 'project': exists = await this.projectRepository.existsBy({ id: resourceId }); break; - case 'dataTable': - exists = await this.dataTableRepository.existsBy({ id: resourceId }); - break; case 'folder': exists = await this.folderRepository.existsBy({ id: resourceId }); break; + case 'dataTable': case 'agent': - exists = await this.agentRepository.existsBy({ id: resourceId }); + exists = (await this.resourceResolvers.get(resourceType)?.exists(resourceId)) ?? false; break; }