diff --git a/server/src/modules/entity-manager/duplicate-compute.service.test.ts b/server/src/modules/entity-manager/duplicate-compute.service.test.ts index ce45ced2c..5af171116 100644 --- a/server/src/modules/entity-manager/duplicate-compute.service.test.ts +++ b/server/src/modules/entity-manager/duplicate-compute.service.test.ts @@ -1,5 +1,15 @@ import { DuplicateComputeService } from './duplicate-compute.service'; +const scope = { libraryIds: [3, 8] }; + +function flattenSql(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(flattenSql).join(' '); + if (!value || typeof value !== 'object') return ''; + const record = value as Record; + return [flattenSql(record.value), flattenSql(record.queryChunks)].join(' '); +} + function makeService() { const where = vi.fn().mockResolvedValue([]); const from = vi.fn().mockReturnValue({ where }); @@ -130,15 +140,49 @@ describe('DuplicateComputeService', () => { const { service, db } = makeService(); db.execute.mockResolvedValue({ rows: [{ idA: 1, idB: 2, simScore: 0.9 }] }); - await expect(service.readCandidatePairs('author', 0.7)).resolves.toEqual([{ idA: 1, idB: 2, simScore: 0.9 }]); + await expect(service.readCandidatePairs('author', 0.7, scope)).resolves.toEqual([{ idA: 1, idB: 2, simScore: 0.9 }]); expect(db.execute).toHaveBeenCalledTimes(1); + + const query = flattenSql(db.execute.mock.calls[0]![0]).replace(/\s+/g, ' '); + expect(query).toContain('book_authors'); + expect(query.match(/scoped_relation/g)?.length).toBeGreaterThanOrEqual(4); }); it('returns an empty array when no rows exist', async () => { const { service, db } = makeService(); db.execute.mockResolvedValue({ rows: [] }); - await expect(service.readCandidatePairs('author', 0.7)).resolves.toEqual([]); + await expect(service.readCandidatePairs('author', 0.7, scope)).resolves.toEqual([]); + }); + + it.each([ + ['author', 'book_authors'], + ['genre', 'book_genres'], + ['tag', 'book_tags'], + ['narrator', 'book_narrators'], + ['series', 'book_series_memberships'], + ] as const)('scopes %s candidates through %s', async (entityType, relationTable) => { + const { service, db } = makeService(); + + await service.readCandidatePairs(entityType, 0.7, scope); + + expect(flattenSql(db.execute.mock.calls[0]![0])).toContain(relationTable); + }); + + it('returns no candidates without querying when no libraries are accessible', async () => { + const { service, db } = makeService(); + + await expect(service.readCandidatePairs('author', 0.7, { libraryIds: [] })).resolves.toEqual([]); + + expect(db.execute).not.toHaveBeenCalled(); + }); + + it('does not serve inline entity types from the global candidate cache', async () => { + const { service, db } = makeService(); + + await expect(service.readCandidatePairs('publisher', 0.7, scope)).resolves.toEqual([]); + + expect(db.execute).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/modules/entity-manager/duplicate-compute.service.ts b/server/src/modules/entity-manager/duplicate-compute.service.ts index 759274c3c..89ef89e9a 100644 --- a/server/src/modules/entity-manager/duplicate-compute.service.ts +++ b/server/src/modules/entity-manager/duplicate-compute.service.ts @@ -2,17 +2,26 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { eq, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; -import type { EntityType } from '@bookorbit/types'; +import type { EntityType, FirstClassEntityType } from '@bookorbit/types'; import { INLINE_ENTITY_TYPES } from '@bookorbit/types'; +import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils'; import { DB } from '../../db'; import * as schema from '../../db/schema'; -import { entityDuplicateCandidates, entityDuplicateScanStatus } from '../../db/schema'; -import type { EntityStrategy } from './strategies/entity-strategy.interface'; +import { books, entityDuplicateCandidates, entityDuplicateScanStatus } from '../../db/schema'; +import type { EntityBookScope, EntityStrategy } from './strategies/entity-strategy.interface'; type Db = NodePgDatabase; const BATCH_SIZE = 1000; +const ENTITY_BOOK_RELATIONS: Record = { + author: { table: 'book_authors', entityIdColumn: 'author_id' }, + genre: { table: 'book_genres', entityIdColumn: 'genre_id' }, + tag: { table: 'book_tags', entityIdColumn: 'tag_id' }, + narrator: { table: 'book_narrators', entityIdColumn: 'narrator_id' }, + series: { table: 'book_series_memberships', entityIdColumn: 'series_id' }, +}; + @Injectable() export class DuplicateComputeService { private readonly logger = new Logger(DuplicateComputeService.name); @@ -55,6 +64,7 @@ export class DuplicateComputeService { async readCandidatePairs( entityType: EntityType, minSimilarity: number, + scope: EntityBookScope, ): Promise< { idA: number; @@ -62,6 +72,23 @@ export class DuplicateComputeService { simScore: number; }[] > { + if (scope.libraryIds.length === 0 || this.isInline(entityType)) return []; + + const relation = ENTITY_BOOK_RELATIONS[entityType as FirstClassEntityType]; + const relationTable = sql.raw(relation.table); + const relationEntityId = sql.raw(`scoped_relation.${relation.entityIdColumn}`); + const relationBookId = sql.raw('scoped_relation.book_id'); + const scopeClauses = [ + sql`${books.libraryId} IN (${sql.join( + scope.libraryIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ]; + if (scope.contentFilters) { + scopeClauses.push(...buildContentFilterClauses(scope.contentFilters, this.db)); + } + const bookScope = sql.join(scopeClauses, sql` AND `); + const rows = await this.db.execute<{ idA: number; idB: number; simScore: number }>(sql` SELECT c.entity_id_a AS "idA", @@ -70,6 +97,20 @@ export class DuplicateComputeService { FROM entity_duplicate_candidates c WHERE c.entity_type = ${entityType} AND c.sim_score >= ${minSimilarity} + AND EXISTS ( + SELECT 1 + FROM ${relationTable} scoped_relation + JOIN ${books} ON ${relationBookId} = ${books.id} + WHERE ${relationEntityId} = c.entity_id_a + AND ${bookScope} + ) + AND EXISTS ( + SELECT 1 + FROM ${relationTable} scoped_relation + JOIN ${books} ON ${relationBookId} = ${books.id} + WHERE ${relationEntityId} = c.entity_id_b + AND ${bookScope} + ) AND NOT EXISTS ( SELECT 1 FROM dismissed_duplicate_pairs d WHERE d.entity_type = ${entityType} diff --git a/server/src/modules/entity-manager/entity-manager.controller.test.ts b/server/src/modules/entity-manager/entity-manager.controller.test.ts index d31bd32f5..fd5b1c9b5 100644 --- a/server/src/modules/entity-manager/entity-manager.controller.test.ts +++ b/server/src/modules/entity-manager/entity-manager.controller.test.ts @@ -182,16 +182,16 @@ describe('EntityManagerController', () => { describe('getEntityInfo', () => { it('handles numeric entity ID', async () => { const { controller, service } = makeController(); - await controller.getEntityInfo('author', '42'); + await controller.getEntityInfo('author', '42', mockUser); - expect(service.getEntityInfo).toHaveBeenCalledWith('author', 42); + expect(service.getEntityInfo).toHaveBeenCalledWith('author', mockUser, 42); }); it('handles string entity ID for inline types', async () => { const { controller, service } = makeController(); - await controller.getEntityInfo('publisher', 'Test Publisher'); + await controller.getEntityInfo('publisher', 'Test Publisher', mockUser); - expect(service.getEntityInfo).toHaveBeenCalledWith('publisher', 'Test Publisher'); + expect(service.getEntityInfo).toHaveBeenCalledWith('publisher', mockUser, 'Test Publisher'); }); }); }); diff --git a/server/src/modules/entity-manager/entity-manager.controller.ts b/server/src/modules/entity-manager/entity-manager.controller.ts index c1157bdb1..bab570a0d 100644 --- a/server/src/modules/entity-manager/entity-manager.controller.ts +++ b/server/src/modules/entity-manager/entity-manager.controller.ts @@ -150,8 +150,8 @@ export class EntityManagerController { @Get(':entityType/info/:entityId') @RequirePermission(Permission.LibraryEditMetadata) - getEntityInfo(@Param('entityType', EntityTypePipe) entityType: EntityType, @Param('entityId') entityId: string) { + getEntityInfo(@Param('entityType', EntityTypePipe) entityType: EntityType, @Param('entityId') entityId: string, @CurrentUser() user: RequestUser) { const parsedId = isInline(entityType) ? entityId : Number(entityId); - return this.service.getEntityInfo(entityType, parsedId); + return this.service.getEntityInfo(entityType, user, parsedId); } } diff --git a/server/src/modules/entity-manager/entity-manager.service.test.ts b/server/src/modules/entity-manager/entity-manager.service.test.ts index 7bc16cee6..46064b609 100644 --- a/server/src/modules/entity-manager/entity-manager.service.test.ts +++ b/server/src/modules/entity-manager/entity-manager.service.test.ts @@ -128,7 +128,7 @@ describe('EntityManagerService', () => { await service.scanDuplicates('publisher', mockUser, 2); - expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([2], 0.5); + expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([2], 0.5, EMPTY_CONTENT_FILTER_RULES); }); it('throws when library not accessible', async () => { @@ -142,7 +142,7 @@ describe('EntityManagerService', () => { const { service, strategies } = makeService(); await service.scanDuplicates('publisher', mockUser, undefined, 0.8); - expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([1, 2], 0.8); + expect(strategies.publisher.findCandidatePairs).toHaveBeenCalledWith([1, 2], 0.8, EMPTY_CONTENT_FILTER_RULES); }); it('filters out dismissed pairs for inline entities', async () => { @@ -298,13 +298,43 @@ describe('EntityManagerService', () => { const result = await service.scanDuplicates('author', mockUser); - expect(duplicateCompute.readCandidatePairs).toHaveBeenCalledWith('author', 0.5); + expect(duplicateCompute.readCandidatePairs).toHaveBeenCalledWith('author', 0.5, { + libraryIds: [1, 2], + contentFilters: EMPTY_CONTENT_FILTER_RULES, + }); expect(repo.getDismissedPairSet).not.toHaveBeenCalled(); expect(strategies.author.findCandidatePairs).not.toHaveBeenCalled(); expect(result.clusters).toHaveLength(1); expect(result.clusters[0]!.entities).toHaveLength(3); }); + it('uses only the explicitly requested accessible library for stored candidates and enrichment', async () => { + const { service, duplicateCompute, libraryService, strategies } = makeService(); + libraryService.findAccessibleLibraryIds.mockResolvedValue([4, 7]); + duplicateCompute.readCandidatePairs.mockResolvedValue([{ idA: 1, idB: 2, simScore: 0.9 }]); + (strategies.author.findEntityById as any).mockImplementation((id: number) => Promise.resolve({ id, name: `Author ${id}` })); + + await service.scanDuplicates('author', mockUser, 7); + + const scope = { libraryIds: [7], contentFilters: EMPTY_CONTENT_FILTER_RULES }; + expect(duplicateCompute.readCandidatePairs).toHaveBeenCalledWith('author', 0.5, scope); + expect(strategies.author.getBookCount).toHaveBeenCalledWith(1, scope); + expect(strategies.author.getBookCount).toHaveBeenCalledWith(2, scope); + expect(strategies.author.getBookTitles).toHaveBeenCalledWith(1, 5, scope); + expect(strategies.author.getBookTitles).toHaveBeenCalledWith(2, 5, scope); + }); + + it('does not query candidates when the user has no accessible libraries', async () => { + const { service, duplicateCompute, libraryService, strategies } = makeService(); + libraryService.findAccessibleLibraryIds.mockResolvedValue([]); + + await expect(service.scanDuplicates('author', mockUser)).resolves.toMatchObject({ clusters: [], total: 0, totalEntities: 0 }); + + expect(duplicateCompute.readCandidatePairs).not.toHaveBeenCalled(); + expect(duplicateCompute.triggerCompute).not.toHaveBeenCalled(); + expect(strategies.author.findCandidatePairs).not.toHaveBeenCalled(); + }); + it('triggers duplicate compute with the requested threshold when no status exists', async () => { const { service, duplicateCompute, strategies } = makeService(); @@ -767,7 +797,7 @@ describe('EntityManagerService', () => { const result = await service.split('author', mockUser, 5, ['Author A', 'Author B'], false); - expect(strategies.author.split).toHaveBeenCalledWith({ entityId: 5, newNames: ['Author A', 'Author B'] }); + expect(strategies.author.split).toHaveBeenCalledWith({ entityId: 5, newNames: ['Author A', 'Author B'], libraryIds: [1, 2] }); expect(repo.deleteDismissedPairsForEntity).toHaveBeenCalledWith('author', 5); expect(result).toEqual({ originalId: 5, @@ -887,16 +917,37 @@ describe('EntityManagerService', () => { (strategies.author.getBookCount as any).mockResolvedValue(5); (strategies.author.getBookTitles as any).mockResolvedValue(['Book 1', 'Book 2']); - const result = await service.getEntityInfo('author', 1); + const result = await service.getEntityInfo('author', mockUser, 1); expect(result).toEqual({ id: 1, name: 'Author A', bookCount: 5, bookTitles: ['Book 1', 'Book 2'] }); + expect(strategies.author.getBookCount).toHaveBeenCalledWith(1, { + libraryIds: [1, 2], + contentFilters: EMPTY_CONTENT_FILTER_RULES, + }); + expect(strategies.author.getBookTitles).toHaveBeenCalledWith(1, 5, { + libraryIds: [1, 2], + contentFilters: EMPTY_CONTENT_FILTER_RULES, + }); }); it('throws NotFoundException when entity not found', async () => { const { service, strategies } = makeService(); (strategies.author.findEntityById as any).mockResolvedValue(null); - await expect(service.getEntityInfo('author', 999)).rejects.toThrow(NotFoundException); + await expect(service.getEntityInfo('author', mockUser, 999)).rejects.toThrow(NotFoundException); + }); + + it('does not expose an entity that has books only outside the accessible scope', async () => { + const { service, strategies } = makeService(); + (strategies.author.findEntityById as any).mockResolvedValue({ id: 1, name: 'Hidden Author' }); + (strategies.author.getBookCount as any).mockResolvedValueOnce(4).mockResolvedValueOnce(0); + + await expect(service.getEntityInfo('author', mockUser, 1)).rejects.toThrow(NotFoundException); + + expect(strategies.author.getBookTitles).toHaveBeenCalledWith(1, 5, { + libraryIds: [1, 2], + contentFilters: EMPTY_CONTENT_FILTER_RULES, + }); }); }); diff --git a/server/src/modules/entity-manager/entity-manager.service.ts b/server/src/modules/entity-manager/entity-manager.service.ts index f146d4fd2..3af5c74ed 100644 --- a/server/src/modules/entity-manager/entity-manager.service.ts +++ b/server/src/modules/entity-manager/entity-manager.service.ts @@ -20,7 +20,7 @@ import { FileWriteService } from '../file-write/file-write.service'; import { LibraryService } from '../library/library.service'; import { EntityManagerRepository } from './entity-manager.repository'; import { DuplicateComputeService } from './duplicate-compute.service'; -import type { EntityStrategy, RawCandidatePair } from './strategies/entity-strategy.interface'; +import type { EntityBookScope, EntityStrategy, RawCandidatePair } from './strategies/entity-strategy.interface'; import { AuthorStrategy } from './strategies/author.strategy'; import { GenreStrategy } from './strategies/genre.strategy'; import { TagStrategy } from './strategies/tag.strategy'; @@ -89,23 +89,26 @@ export class EntityManagerService { try { const strategy = this.getStrategy(entityType); const similarity = minSimilarity ?? DEFAULT_MIN_SIMILARITY; + const allLibraryIds = await this.libraryService.findAccessibleLibraryIds(user); + if (libraryId && !allLibraryIds.includes(libraryId)) throw new BadRequestException('Library not accessible'); - if (libraryId) { - const allLibraryIds = await this.libraryService.findAccessibleLibraryIds(user); - if (!allLibraryIds.includes(libraryId)) throw new BadRequestException('Library not accessible'); - } + const libraryIds = libraryId ? [libraryId] : allLibraryIds; + const bookScope: EntityBookScope = { + libraryIds, + contentFilters: user.isSuperuser ? undefined : user.contentFilters, + }; let rawPairs: RawCandidatePair[]; - if (strategy.isInline) { - const allLibraryIds = await this.libraryService.findAccessibleLibraryIds(user); - const libraryIds = libraryId ? allLibraryIds.filter((id) => id === libraryId) : allLibraryIds; - rawPairs = await strategy.findCandidatePairs(libraryIds, similarity); + if (libraryIds.length === 0) { + rawPairs = []; + } else if (strategy.isInline) { + rawPairs = await strategy.findCandidatePairs(libraryIds, similarity, bookScope.contentFilters); const dismissedSet = await this.repo.getInlineDismissedPairSet(entityType); rawPairs = rawPairs.filter((pair) => !dismissedSet.has(`${pair.idA}:${pair.idB}`)); } else { - const storedPairs = await this.duplicateCompute.readCandidatePairs(entityType, similarity); + const storedPairs = await this.duplicateCompute.readCandidatePairs(entityType, similarity, bookScope); if (storedPairs.length === 0) { const status = await this.duplicateCompute.getStatus(entityType); @@ -135,7 +138,7 @@ export class EntityManagerService { const offset = (clampedPage - 1) * pageSize; const pageSlice = ranked.slice(offset, offset + pageSize); - const enrichedClusters = await this.enrichClusterSlice(strategy, pageSlice, rawPairs); + const enrichedClusters = await this.enrichClusterSlice(strategy, pageSlice, rawPairs, bookScope); this.logger.log( `[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} total=${total} page=${clampedPage} clusters=${enrichedClusters.length} - duplicate scan completed`, @@ -392,7 +395,8 @@ export class EntityManagerService { throw new BadRequestException('Split requires at least 2 new names'); } - const result = await strategy.split({ entityId, newNames }); + const libraryIds = await this.libraryService.findAccessibleLibraryIds(user); + const result = await strategy.split({ entityId, newNames, libraryIds }); await this.repo.deleteDismissedPairsForEntity(entityType, entityId); @@ -491,12 +495,22 @@ export class EntityManagerService { return result; } - async getEntityInfo(entityType: EntityType, entityId: number | string): Promise { + async getEntityInfo(entityType: EntityType, user: RequestUser, entityId: number | string): Promise { const strategy = this.getStrategy(entityType); const entity = await strategy.findEntityById(entityId); if (!entity) throw new NotFoundException(`${entityType} not found`); - const [bookCount, bookTitles] = await Promise.all([strategy.getBookCount(entityId), strategy.getBookTitles(entityId, BOOK_TITLES_LIMIT)]); + const libraryIds = await this.libraryService.findAccessibleLibraryIds(user); + const bookScope: EntityBookScope = { + libraryIds, + contentFilters: user.isSuperuser ? undefined : user.contentFilters, + }; + const [globalBookCount, bookCount, bookTitles] = await Promise.all([ + strategy.getBookCount(entityId), + strategy.getBookCount(entityId, bookScope), + strategy.getBookTitles(entityId, BOOK_TITLES_LIMIT, bookScope), + ]); + if (globalBookCount > 0 && bookCount === 0) throw new NotFoundException(`${entityType} not found`); return { id: entity.id, name: entity.name, bookCount, bookTitles }; } @@ -607,6 +621,7 @@ export class EntityManagerService { strategy: EntityStrategy, rankedSlice: Array<{ memberIds: Set; avgSimilarity: number }>, pairs: RawCandidatePair[], + bookScope: EntityBookScope, ): Promise { const enriched: DuplicateCluster[] = []; let clusterIndex = 0; @@ -617,8 +632,8 @@ export class EntityManagerService { const parsedId = strategy.isInline ? id : Number(id); const [entity, bookCount, bookTitles] = await Promise.all([ strategy.findEntityById(parsedId), - strategy.getBookCount(parsedId), - strategy.getBookTitles(parsedId, BOOK_TITLES_LIMIT), + strategy.getBookCount(parsedId, bookScope), + strategy.getBookTitles(parsedId, BOOK_TITLES_LIMIT, bookScope), ]); return entity ? { id: entity.id, name: entity.name, bookCount, bookTitles, sortName: (entity as any).sortName, hasPhoto: (entity as any).hasPhoto } diff --git a/server/src/modules/entity-manager/strategies/author.strategy.test.ts b/server/src/modules/entity-manager/strategies/author.strategy.test.ts index cac345561..6fd8ebcba 100644 --- a/server/src/modules/entity-manager/strategies/author.strategy.test.ts +++ b/server/src/modules/entity-manager/strategies/author.strategy.test.ts @@ -1,4 +1,4 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { vi } from 'vitest'; import { AuthorStrategy } from './author.strategy'; @@ -25,7 +25,8 @@ function makeStrategy(db: Record = {}, deps: Record { return { select, selectDistinct, update }; } + it('rejects merging authors that are used outside the accessible libraries', async () => { + const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }); + const authorsRepo = { mergeAuthors: vi.fn() }; + const strategy = makeStrategy({ execute }, { authorsRepo }); + + await expect(strategy.merge({ targetId: 1, sourceIds: [2], userId: 5, libraryIds: [7] })).rejects.toBeInstanceOf(ForbiddenException); + + expect(authorsRepo.mergeAuthors).not.toHaveBeenCalled(); + }); + it('throws NotFoundException when target author does not exist', async () => { const limit = vi.fn().mockResolvedValue([]); const where = vi.fn().mockReturnValue({ limit }); @@ -799,6 +810,19 @@ describe('AuthorStrategy', () => { await expect(strategy.getBookCount(1)).resolves.toBe(0); }); + + it('joins books and applies the requested library scope', async () => { + const where = vi.fn().mockResolvedValue([{ count: 2 }]); + const innerJoin = vi.fn().mockReturnValue({ where }); + const from = vi.fn().mockReturnValue({ innerJoin }); + const select = vi.fn().mockReturnValue({ from }); + const strategy = makeStrategy({ select }); + + await expect(strategy.getBookCount(1, { libraryIds: [7, 9] })).resolves.toBe(2); + + expect(innerJoin).toHaveBeenCalledTimes(1); + expect(flattenSql(where.mock.calls[0]![0])).toMatch(/\bin\b/); + }); }); describe('getBookTitles', () => { @@ -815,6 +839,21 @@ describe('AuthorStrategy', () => { await expect(strategy.getBookTitles(1, 5)).resolves.toEqual(['The Hobbit', 'LOTR']); }); + + it('applies the same library scope when loading preview titles', async () => { + const limit = vi.fn().mockResolvedValue([{ title: 'Visible Book' }]); + const orderBy = vi.fn().mockReturnValue({ limit }); + const where = vi.fn().mockReturnValue({ orderBy }); + const leftJoin = vi.fn().mockReturnValue({ where }); + const innerJoin = vi.fn().mockReturnValue({ leftJoin }); + const from = vi.fn().mockReturnValue({ innerJoin }); + const select = vi.fn().mockReturnValue({ from }); + const strategy = makeStrategy({ select }); + + await expect(strategy.getBookTitles(1, 5, { libraryIds: [7] })).resolves.toEqual(['Visible Book']); + + expect(flattenSql(where.mock.calls[0]![0])).toMatch(/\bin\b/); + }); }); describe('findEntityById', () => { diff --git a/server/src/modules/entity-manager/strategies/author.strategy.ts b/server/src/modules/entity-manager/strategies/author.strategy.ts index 33d2ae467..857eb1177 100644 --- a/server/src/modules/entity-manager/strategies/author.strategy.ts +++ b/server/src/modules/entity-manager/strategies/author.strategy.ts @@ -30,7 +30,9 @@ import type { StrategyMergeResult, StrategyRenameResult, StrategySplitResult, + EntityBookScope, } from './entity-strategy.interface'; +import { assertEntityRelationsWithinLibraries, buildEntityBookScopeClauses } from './entity-book-scope'; type Db = NodePgDatabase; @@ -216,6 +218,8 @@ export class AuthorStrategy implements EntityStrategy { const sourceIds = input.sourceIds as number[]; const fieldsResolved: string[] = []; + await this.assertMutationScope([targetId, ...sourceIds], input.libraryIds ?? []); + const targetAuthor = await this.db .select({ id: authors.id, sortName: authors.sortName, description: authors.description, hasPhoto: authors.hasPhoto }) .from(authors) @@ -276,6 +280,7 @@ export class AuthorStrategy implements EntityStrategy { async rename(input: RenameInput): Promise { const entityId = input.entityId as number; + await this.assertMutationScope([entityId], input.libraryIds); const [entity] = await this.db.select({ name: authors.name }).from(authors).where(eq(authors.id, entityId)).limit(1); if (!entity) throw new NotFoundException('Author not found'); @@ -290,7 +295,12 @@ export class AuthorStrategy implements EntityStrategy { .where(eq(NORMALIZED_AUTHOR_NAME_SQL, normalizedName)); const mergeTarget = this.selectPreferredAuthorMatch(existingRows, entityId, displayName); if (mergeTarget) { - const mergeResult = await this.merge({ targetId: mergeTarget.id, sourceIds: [entityId], userId: input.userId }); + const mergeResult = await this.merge({ + targetId: mergeTarget.id, + sourceIds: [entityId], + userId: input.userId, + libraryIds: input.libraryIds ?? [], + }); return { oldName, affectedBookIds: mergeResult.affectedBookIds, wasImplicitMerge: true, mergedEntityId: mergeTarget.id }; } @@ -301,6 +311,7 @@ export class AuthorStrategy implements EntityStrategy { async deleteEntity(input: DeleteInput): Promise { const entityId = input.entityId as number; + await this.assertMutationScope([entityId], input.libraryIds); const [entity] = await this.db.select({ name: authors.name }).from(authors).where(eq(authors.id, entityId)).limit(1); if (!entity) throw new NotFoundException('Author not found'); @@ -318,6 +329,7 @@ export class AuthorStrategy implements EntityStrategy { } async split(input: SplitInput): Promise { + await this.assertMutationScope([input.entityId], input.libraryIds ?? []); const [entity] = await this.db.select({ name: authors.name }).from(authors).where(eq(authors.id, input.entityId)).limit(1); if (!entity) throw new NotFoundException('Author not found'); @@ -379,6 +391,10 @@ export class AuthorStrategy implements EntityStrategy { return chooseCanonicalMetadataTextRow(rows, { desiredName: displayName, excludedId: excludedAuthorId }); } + private assertMutationScope(entityIds: number[], libraryIds: number[]): Promise { + return assertEntityRelationsWithinLibraries(this.db, 'book_authors', 'author_id', entityIds, libraryIds); + } + async findAffectedBookIds(ids: (number | string)[]): Promise { const numericIds = ids as number[]; if (numericIds.length === 0) return []; @@ -386,7 +402,16 @@ export class AuthorStrategy implements EntityStrategy { return rows.map((r) => r.bookId); } - async getBookCount(id: number | string): Promise { + async getBookCount(id: number | string, scope?: EntityBookScope): Promise { + if (scope) { + const [row] = await this.db + .select({ count: count() }) + .from(bookAuthors) + .innerJoin(books, eq(books.id, bookAuthors.bookId)) + .where(and(eq(bookAuthors.authorId, id as number), ...buildEntityBookScopeClauses(this.db, scope))); + return row?.count ?? 0; + } + const [row] = await this.db .select({ count: count() }) .from(bookAuthors) @@ -394,13 +419,13 @@ export class AuthorStrategy implements EntityStrategy { return row?.count ?? 0; } - async getBookTitles(id: number | string, limit: number): Promise { + async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise { const rows = await this.db .select({ title: sql`COALESCE(${bookMetadata.title}, 'Untitled')` }) .from(bookAuthors) .innerJoin(books, eq(books.id, bookAuthors.bookId)) .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) - .where(eq(bookAuthors.authorId, id as number)) + .where(and(eq(bookAuthors.authorId, id as number), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : []))) .orderBy(asc(bookMetadata.title)) .limit(limit); return rows.map((r) => r.title); diff --git a/server/src/modules/entity-manager/strategies/entity-book-scope.test.ts b/server/src/modules/entity-manager/strategies/entity-book-scope.test.ts new file mode 100644 index 000000000..0679af634 --- /dev/null +++ b/server/src/modules/entity-manager/strategies/entity-book-scope.test.ts @@ -0,0 +1,50 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { assertEntityRelationsWithinLibraries } from './entity-book-scope'; + +function flattenSql(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(flattenSql).join(' '); + if (!value || typeof value !== 'object') return ''; + const record = value as Record; + return [flattenSql(record.value), flattenSql(record.queryChunks)].join(' '); +} + +describe('assertEntityRelationsWithinLibraries', () => { + it('allows a mutation when no entity relations exist outside the accessible libraries', async () => { + const execute = vi.fn().mockResolvedValue({ rows: [] }); + + await expect(assertEntityRelationsWithinLibraries({ execute } as never, 'book_authors', 'author_id', [10, 20], [3, 4])).resolves.toBeUndefined(); + + const query = flattenSql(execute.mock.calls[0]![0]).replace(/\s+/g, ' '); + expect(query).toContain('book_authors'); + expect(query).toContain('scoped_relation.author_id'); + expect(query).toContain('NOT IN'); + }); + + it('rejects a mutation when any entity relation belongs to an inaccessible library', async () => { + const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }); + + await expect(assertEntityRelationsWithinLibraries({ execute } as never, 'book_tags', 'tag_id', [7], [2])).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('treats every existing relation as inaccessible when the user has no libraries', async () => { + const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }); + + await expect(assertEntityRelationsWithinLibraries({ execute } as never, 'book_genres', 'genre_id', [5], [])).rejects.toBeInstanceOf( + ForbiddenException, + ); + + expect(flattenSql(execute.mock.calls[0]![0])).toContain('true'); + }); + + it('skips the database query when there are no entity IDs', async () => { + const execute = vi.fn(); + + await assertEntityRelationsWithinLibraries({ execute } as never, 'book_authors', 'author_id', [], [1]); + + expect(execute).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/entity-manager/strategies/entity-book-scope.ts b/server/src/modules/entity-manager/strategies/entity-book-scope.ts new file mode 100644 index 000000000..90d2addb0 --- /dev/null +++ b/server/src/modules/entity-manager/strategies/entity-book-scope.ts @@ -0,0 +1,54 @@ +import { ForbiddenException } from '@nestjs/common'; +import { inArray, sql, type SQL } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils'; +import * as schema from '../../../db/schema'; +import { books } from '../../../db/schema'; +import type { EntityBookScope } from './entity-strategy.interface'; + +type Db = NodePgDatabase; + +export function buildEntityBookScopeClauses(db: Db, scope: EntityBookScope): SQL[] { + if (scope.libraryIds.length === 0) return [sql`false`]; + + return [inArray(books.libraryId, scope.libraryIds), ...(scope.contentFilters ? buildContentFilterClauses(scope.contentFilters, db) : [])]; +} + +export async function assertEntityRelationsWithinLibraries( + db: Db, + relationTableName: string, + relationEntityIdColumn: string, + entityIds: number[], + libraryIds: number[], +): Promise { + if (entityIds.length === 0) return; + + const relationTable = sql.raw(relationTableName); + const relationEntityId = sql.raw(`scoped_relation.${relationEntityIdColumn}`); + const relationBookId = sql.raw('scoped_relation.book_id'); + const entityIdList = sql.join( + entityIds.map((id) => sql`${id}`), + sql`, `, + ); + const outsideLibraryCondition = + libraryIds.length > 0 + ? sql`${books.libraryId} NOT IN (${sql.join( + libraryIds.map((id) => sql`${id}`), + sql`, `, + )})` + : sql`true`; + + const result = await db.execute(sql` + SELECT 1 + FROM ${relationTable} scoped_relation + JOIN ${books} ON ${relationBookId} = ${books.id} + WHERE ${relationEntityId} IN (${entityIdList}) + AND ${outsideLibraryCondition} + LIMIT 1 + `); + + if (result.rows.length > 0) { + throw new ForbiddenException('Entity is used by a library outside the accessible scope'); + } +} diff --git a/server/src/modules/entity-manager/strategies/entity-strategy.interface.ts b/server/src/modules/entity-manager/strategies/entity-strategy.interface.ts index 1372b76c6..47d89e91b 100644 --- a/server/src/modules/entity-manager/strategies/entity-strategy.interface.ts +++ b/server/src/modules/entity-manager/strategies/entity-strategy.interface.ts @@ -1,4 +1,9 @@ -import type { BrowseEntityBookCountFilter, BrowseEntitySortBy, BrowseEntitySortOrder, EntityType } from '@bookorbit/types'; +import type { BrowseEntityBookCountFilter, BrowseEntitySortBy, BrowseEntitySortOrder, ContentFilterRules, EntityType } from '@bookorbit/types'; + +export interface EntityBookScope { + libraryIds: number[]; + contentFilters?: ContentFilterRules; +} export interface RawCandidatePair { idA: number | string; @@ -69,6 +74,7 @@ export interface StrategyDeleteResult { export interface SplitInput { entityId: number; newNames: string[]; + libraryIds?: number[]; } export interface StrategySplitResult { @@ -81,7 +87,7 @@ export interface EntityStrategy { readonly entityType: EntityType; readonly isInline: boolean; - findCandidatePairs(libraryIds: number[], minSimilarity: number): Promise; + findCandidatePairs(libraryIds: number[], minSimilarity: number, contentFilters?: ContentFilterRules): Promise; getAllEntityIds?(): Promise; @@ -95,7 +101,7 @@ export interface EntityStrategy { split(input: SplitInput): Promise; findAffectedBookIds(ids: (number | string)[]): Promise; - getBookCount(id: number | string): Promise; - getBookTitles(id: number | string, limit: number): Promise; + getBookCount(id: number | string, scope?: EntityBookScope): Promise; + getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise; findEntityById(id: number | string): Promise<{ id: number | string; name: string } | null>; } diff --git a/server/src/modules/entity-manager/strategies/inline-entity.strategy.test.ts b/server/src/modules/entity-manager/strategies/inline-entity.strategy.test.ts index e43dc63d7..f32a322c8 100644 --- a/server/src/modules/entity-manager/strategies/inline-entity.strategy.test.ts +++ b/server/src/modules/entity-manager/strategies/inline-entity.strategy.test.ts @@ -12,6 +12,14 @@ function makeStrategy(db: Record = {}) { return new TestInlineStrategy(db as never); } +function flattenSql(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(flattenSql).join(' '); + if (!value || typeof value !== 'object') return ''; + const record = value as Record; + return [flattenSql(record.value), flattenSql(record.queryChunks)].join(' '); +} + function makeSelectChain(rows: unknown[]) { const limit = vi.fn().mockResolvedValue(rows); const orderBy = vi.fn().mockReturnValue({ limit }); @@ -64,6 +72,25 @@ describe('InlineEntityStrategy', () => { expect(Number(capturedThreshold)).toBe(1); }); + + it('applies content filters to both candidate values', async () => { + const execute = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce({ rows: [] }); + const transaction = vi.fn().mockImplementation(async (cb: (tx: { execute: typeof execute }) => Promise) => cb({ execute })); + const where = vi.fn().mockReturnValue({ queryChunks: ['book_tags'] }); + const from = vi.fn().mockReturnValue({ where }); + const strategy = makeStrategy({ transaction, select: vi.fn().mockReturnValue({ from }) }); + + await strategy.findCandidatePairs([1], 0.5, { + includeTagIds: [9], + excludeTagIds: [], + includeGenreIds: [], + excludeGenreIds: [], + }); + + const query = flattenSql(execute.mock.calls[1]![0]); + expect(query).toContain('book_tags'); + expect(query.match(/book_tags/g)?.length).toBeGreaterThanOrEqual(2); + }); }); describe('browse', () => { @@ -93,11 +120,13 @@ describe('InlineEntityStrategy', () => { describe('merge', () => { it('calls execute once per source value and returns affected book ids', async () => { - const execute = vi.fn().mockResolvedValue({ rows: [] }); + const execute = vi + .fn() + .mockResolvedValueOnce({ rows: [{ bookId: 10 }, { bookId: 20 }] }) + .mockResolvedValueOnce({ rows: [{ bookId: 20 }] }); const strategy = makeStrategy({ execute }); - vi.spyOn(strategy, 'findAffectedBookIds').mockResolvedValue([10, 20]); - const result = await strategy.merge({ targetId: 'Alice', sourceIds: ['Alicia', 'Ali'], userId: 1 }); + const result = await strategy.merge({ targetId: 'Alice', sourceIds: ['Alicia', 'Ali'], userId: 1, libraryIds: [7] }); expect(execute).toHaveBeenCalledTimes(2); expect(result).toEqual({ affectedBookIds: [10, 20] }); @@ -106,9 +135,19 @@ describe('InlineEntityStrategy', () => { it('does nothing when sourceIds is empty', async () => { const execute = vi.fn().mockResolvedValue({ rows: [] }); const strategy = makeStrategy({ execute }); - vi.spyOn(strategy, 'findAffectedBookIds').mockResolvedValue([]); - await strategy.merge({ targetId: 'Alice', sourceIds: [], userId: 1 }); + await strategy.merge({ targetId: 'Alice', sourceIds: [], userId: 1, libraryIds: [7] }); + + expect(execute).not.toHaveBeenCalled(); + }); + + it('does not update metadata when no libraries are accessible', async () => { + const execute = vi.fn(); + const strategy = makeStrategy({ execute }); + + await expect(strategy.merge({ targetId: 'Alice', sourceIds: ['Alicia'], userId: 1, libraryIds: [] })).resolves.toEqual({ + affectedBookIds: [], + }); expect(execute).not.toHaveBeenCalled(); }); diff --git a/server/src/modules/entity-manager/strategies/inline-entity.strategy.ts b/server/src/modules/entity-manager/strategies/inline-entity.strategy.ts index 73e74d225..7eb9e2951 100644 --- a/server/src/modules/entity-manager/strategies/inline-entity.strategy.ts +++ b/server/src/modules/entity-manager/strategies/inline-entity.strategy.ts @@ -1,12 +1,13 @@ import { BadRequestException, Inject } from '@nestjs/common'; -import { SQL, asc, count, eq, sql } from 'drizzle-orm'; +import { SQL, and, asc, count, eq, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; -import type { InlineEntityType } from '@bookorbit/types'; +import type { ContentFilterRules, InlineEntityType } from '@bookorbit/types'; import { DB } from '../../../db'; import { accentInsensitiveIlike } from '../../../common/utils/accent-insensitive-search.utils'; +import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils'; import * as schema from '../../../db/schema'; -import { bookMetadata } from '../../../db/schema'; +import { bookMetadata, books } from '../../../db/schema'; import type { BrowseParams, BrowseResult, @@ -20,7 +21,9 @@ import type { StrategyMergeResult, StrategyRenameResult, StrategySplitResult, + EntityBookScope, } from './entity-strategy.interface'; +import { buildEntityBookScopeClauses } from './entity-book-scope'; type Db = NodePgDatabase; @@ -50,17 +53,29 @@ export abstract class InlineEntityStrategy implements EntityStrategy { return sql`${sql.raw(`${alias}.${this.rawFieldName}`)} = ${value}`; } - async findCandidatePairs(libraryIds: number[], minSimilarity: number): Promise { + async findCandidatePairs(libraryIds: number[], minSimilarity: number, contentFilters?: ContentFilterRules): Promise { const similarityThreshold = Math.max(0.1, Math.min(1, minSimilarity)); const f = sql.raw(this.rawFieldName); const hasLibraryFilter = libraryIds.length > 0; const idsLiteral = hasLibraryFilter ? sql.raw(`(${libraryIds.join(',')})`) : sql``; + const contentFilterClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : []; + const contentFilterSql = contentFilterClauses.length > 0 ? sql`AND ${sql.join(contentFilterClauses, sql` AND `)}` : sql``; const v1LibraryFilter = hasLibraryFilter - ? sql`AND EXISTS (SELECT 1 FROM books b1 WHERE b1.id = bm1.book_id AND b1.library_id IN ${idsLiteral})` + ? sql`AND EXISTS ( + SELECT 1 FROM ${books} + WHERE ${books.id} = bm1.book_id + AND ${books.libraryId} IN ${idsLiteral} + ${contentFilterSql} + )` : sql``; const v2LibraryFilter = hasLibraryFilter - ? sql`AND EXISTS (SELECT 1 FROM books b2 WHERE b2.id = bm2.book_id AND b2.library_id IN ${idsLiteral})` + ? sql`AND EXISTS ( + SELECT 1 FROM ${books} + WHERE ${books.id} = bm2.book_id + AND ${books.libraryId} IN ${idsLiteral} + ${contentFilterSql} + )` : sql``; const rows = await this.db.transaction(async (tx) => { @@ -141,19 +156,29 @@ export abstract class InlineEntityStrategy implements EntityStrategy { async merge(input: MergeInput): Promise { const targetValue = input.targetId as string; const sourceValues = input.sourceIds as string[]; + const libraryIds = input.libraryIds ?? []; - const affectedBookIds = await this.findAffectedBookIds(sourceValues); + if (sourceValues.length === 0 || libraryIds.length === 0) return { affectedBookIds: [] }; + + const libraryIdList = sql.join( + libraryIds.map((id) => sql`${id}`), + sql`, `, + ); + const affectedBookIds = new Set(); for (const sourceValue of sourceValues) { - await this.db.execute(sql` + const affectedRows = await this.db.execute<{ bookId: number }>(sql` UPDATE book_metadata bm SET ${sql.raw(this.rawFieldName)} = ${targetValue} FROM books b WHERE bm.book_id = b.id + AND b.library_id IN (${libraryIdList}) AND bm.${sql.raw(this.rawFieldName)} = ${sourceValue} + RETURNING bm.book_id AS "bookId" `); + for (const row of affectedRows.rows) affectedBookIds.add(row.bookId); } - return { affectedBookIds }; + return { affectedBookIds: [...affectedBookIds] }; } async rename(input: RenameInput): Promise { @@ -161,6 +186,10 @@ export abstract class InlineEntityStrategy implements EntityStrategy { const trimmed = this.normalizeInputValue(input.newName); if (!trimmed) throw new BadRequestException('Name cannot be empty'); + if (input.libraryIds.length === 0) { + return { oldName: currentValue, affectedBookIds: [], wasImplicitMerge: false }; + } + const idsLiteral = sql.raw(`(${input.libraryIds.join(',')})`); const existingCount = await this.db.execute<{ cnt: number }>(sql` @@ -192,6 +221,8 @@ export abstract class InlineEntityStrategy implements EntityStrategy { async deleteEntity(input: DeleteInput): Promise { const value = input.entityId as string; + if (input.libraryIds.length === 0) return { name: value, affectedBookIds: [] }; + const idsLiteral = sql.raw(`(${input.libraryIds.join(',')})`); const affectedRows = await this.db.execute<{ bookId: number }>(sql` @@ -227,18 +258,27 @@ export abstract class InlineEntityStrategy implements EntityStrategy { return rows.rows.map((r) => r.bookId); } - async getBookCount(id: number | string): Promise { + async getBookCount(id: number | string, scope?: EntityBookScope): Promise { const value = id as string; + if (scope) { + const [row] = await this.db + .select({ count: count() }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(eq(this.field, value), ...buildEntityBookScopeClauses(this.db, scope))); + return row?.count ?? 0; + } + const [row] = await this.db.select({ count: count() }).from(bookMetadata).where(eq(this.field, value)); return row?.count ?? 0; } - async getBookTitles(id: number | string, limit: number): Promise { + async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise { const value = id as string; - const rows = await this.db - .select({ title: sql`COALESCE(${bookMetadata.title}, 'Untitled')` }) - .from(bookMetadata) - .where(eq(this.field, value)) + const query = this.db.select({ title: sql`COALESCE(${bookMetadata.title}, 'Untitled')` }).from(bookMetadata); + const scopedQuery = scope ? query.innerJoin(books, eq(books.id, bookMetadata.bookId)) : query; + const rows = await scopedQuery + .where(and(eq(this.field, value), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : []))) .orderBy(asc(bookMetadata.title)) .limit(limit); return rows.map((r) => r.title); diff --git a/server/src/modules/entity-manager/strategies/junction-entity.strategy.test.ts b/server/src/modules/entity-manager/strategies/junction-entity.strategy.test.ts index 0829fcd98..3ffe82cb9 100644 --- a/server/src/modules/entity-manager/strategies/junction-entity.strategy.test.ts +++ b/server/src/modules/entity-manager/strategies/junction-entity.strategy.test.ts @@ -1,9 +1,9 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { TagStrategy } from './tag.strategy'; function makeStrategy(db: Record = {}) { - return new TagStrategy(db as never); + return new TagStrategy({ execute: vi.fn().mockResolvedValue({ rows: [] }), ...db } as never); } function makeSelectChain(rows: unknown[]) { @@ -223,6 +223,16 @@ describe('TagStrategy (JunctionEntityStrategy)', () => { }); describe('merge', () => { + it('rejects merging entities that are used outside the accessible libraries', async () => { + const execute = vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }); + const strategy = makeStrategy({ execute }); + vi.spyOn(strategy, 'findEntityById'); + + await expect(strategy.merge({ targetId: 5, sourceIds: [1], userId: 1, libraryIds: [2] })).rejects.toBeInstanceOf(ForbiddenException); + + expect(strategy.findEntityById).not.toHaveBeenCalled(); + }); + it('throws NotFoundException when target entity is not found', async () => { const { select } = makeSelectChain([]); const strategy = makeStrategy({ select }); diff --git a/server/src/modules/entity-manager/strategies/junction-entity.strategy.ts b/server/src/modules/entity-manager/strategies/junction-entity.strategy.ts index f3d807e12..5fea0c3df 100644 --- a/server/src/modules/entity-manager/strategies/junction-entity.strategy.ts +++ b/server/src/modules/entity-manager/strategies/junction-entity.strategy.ts @@ -21,7 +21,9 @@ import type { StrategyMergeResult, StrategyRenameResult, StrategySplitResult, + EntityBookScope, } from './entity-strategy.interface'; +import { assertEntityRelationsWithinLibraries, buildEntityBookScopeClauses } from './entity-book-scope'; type Db = NodePgDatabase; @@ -194,6 +196,8 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { const targetId = input.targetId as number; const sourceIds = input.sourceIds as number[]; + await this.assertMutationScope([targetId, ...sourceIds], input.libraryIds ?? []); + const target = await this.findEntityById(targetId); if (!target) throw new NotFoundException(`${this.entityType} not found`); @@ -221,6 +225,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { async rename(input: RenameInput): Promise { const entityId = input.entityId as number; + await this.assertMutationScope([entityId], input.libraryIds); const [entity] = await this.db.select({ name: this.nameCol }).from(this.entityTable).where(eq(this.entityIdCol, entityId)).limit(1); if (!entity) throw new NotFoundException(`${this.entityType} not found`); @@ -231,7 +236,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { const [existing] = await this.db.select({ id: this.entityIdCol }).from(this.entityTable).where(eq(this.nameCol, trimmed)).limit(1); if (existing && (existing as any).id !== entityId) { const existingId = (existing as any).id as number; - const mergeResult = await this.merge({ targetId: existingId, sourceIds: [entityId], userId: input.userId }); + const mergeResult = await this.merge({ targetId: existingId, sourceIds: [entityId], userId: input.userId, libraryIds: input.libraryIds ?? [] }); return { oldName, affectedBookIds: mergeResult.affectedBookIds, wasImplicitMerge: true, mergedEntityId: existingId }; } @@ -242,6 +247,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { async deleteEntity(input: DeleteInput): Promise { const entityId = input.entityId as number; + await this.assertMutationScope([entityId], input.libraryIds); const [entity] = await this.db.select({ name: this.nameCol }).from(this.entityTable).where(eq(this.entityIdCol, entityId)).limit(1); if (!entity) throw new NotFoundException(`${this.entityType} not found`); @@ -264,6 +270,7 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { } async split(input: SplitInput): Promise { + await this.assertMutationScope([input.entityId], input.libraryIds ?? []); const [entity] = await this.db.select({ name: this.nameCol }).from(this.entityTable).where(eq(this.entityIdCol, input.entityId)).limit(1); if (!entity) throw new NotFoundException(`${this.entityType} not found`); @@ -313,7 +320,16 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { return rows.map((r: any) => r.bookId); } - async getBookCount(id: number | string): Promise { + async getBookCount(id: number | string, scope?: EntityBookScope): Promise { + if (scope) { + const [row] = await this.db + .select({ count: count() }) + .from(this.junctionTable) + .innerJoin(books, eq(books.id, this.junctionBookIdCol)) + .where(and(eq(this.junctionEntityIdCol, id as number), ...buildEntityBookScopeClauses(this.db, scope))); + return row?.count ?? 0; + } + const [row] = await this.db .select({ count: count() }) .from(this.junctionTable) @@ -321,13 +337,13 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { return row?.count ?? 0; } - async getBookTitles(id: number | string, limit: number): Promise { + async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise { const rows = await this.db .select({ title: sql`COALESCE(${bookMetadata.title}, 'Untitled')` }) .from(this.junctionTable) .innerJoin(books, eq(books.id, this.junctionBookIdCol)) .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) - .where(eq(this.junctionEntityIdCol, id as number)) + .where(and(eq(this.junctionEntityIdCol, id as number), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : []))) .orderBy(asc(bookMetadata.title)) .limit(limit); return rows.map((r) => r.title); @@ -344,4 +360,8 @@ export abstract class JunctionEntityStrategy implements EntityStrategy { } protected abstract buildJunctionRow(bookId: number, entityId: number): Record; + + private assertMutationScope(entityIds: number[], libraryIds: number[]): Promise { + return assertEntityRelationsWithinLibraries(this.db, this.rawJunctionTable, this.rawEntityIdCol, entityIds, libraryIds); + } } diff --git a/server/src/modules/entity-manager/strategies/narrator.strategy.ts b/server/src/modules/entity-manager/strategies/narrator.strategy.ts index 974dc5778..f3f6cc59e 100644 --- a/server/src/modules/entity-manager/strategies/narrator.strategy.ts +++ b/server/src/modules/entity-manager/strategies/narrator.strategy.ts @@ -1,11 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; -import { asc, eq, sql } from 'drizzle-orm'; +import { and, asc, eq, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../../db'; import * as schema from '../../../db/schema'; import { bookMetadata, bookNarrators, books, narrators } from '../../../db/schema'; -import type { BrowseParams, BrowseResult } from './entity-strategy.interface'; +import type { BrowseParams, BrowseResult, EntityBookScope } from './entity-strategy.interface'; +import { buildEntityBookScopeClauses } from './entity-book-scope'; import { JunctionEntityStrategy } from './junction-entity.strategy'; type Db = NodePgDatabase; @@ -49,13 +50,13 @@ export class NarratorStrategy extends JunctionEntityStrategy { }; } - override async getBookTitles(id: number | string, limit: number): Promise { + override async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise { const rows = await this.db .select({ title: sql`COALESCE(${bookMetadata.title}, 'Untitled')` }) .from(bookNarrators) .innerJoin(books, eq(books.id, bookNarrators.bookId)) .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) - .where(eq(bookNarrators.narratorId, id as number)) + .where(and(eq(bookNarrators.narratorId, id as number), ...(scope ? buildEntityBookScopeClauses(this.db, scope) : []))) .orderBy(asc(bookMetadata.title)) .limit(limit); return rows.map((r) => r.title); diff --git a/server/src/modules/entity-manager/strategies/series.strategy.ts b/server/src/modules/entity-manager/strategies/series.strategy.ts index e8c35520f..5f10f3007 100644 --- a/server/src/modules/entity-manager/strategies/series.strategy.ts +++ b/server/src/modules/entity-manager/strategies/series.strategy.ts @@ -20,7 +20,9 @@ import type { StrategyMergeResult, StrategyRenameResult, StrategySplitResult, + EntityBookScope, } from './entity-strategy.interface'; +import { buildEntityBookScopeClauses } from './entity-book-scope'; type Db = NodePgDatabase; @@ -267,7 +269,16 @@ export class SeriesStrategy implements EntityStrategy { return [...new Set(rows.map((row) => row.bookId))]; } - async getBookCount(id: number | string): Promise { + async getBookCount(id: number | string, scope?: EntityBookScope): Promise { + if (scope) { + const [row] = await this.db + .select({ count: count() }) + .from(bookSeriesMemberships) + .innerJoin(books, eq(books.id, bookSeriesMemberships.bookId)) + .where(and(eq(bookSeriesMemberships.seriesId, Number(id)), ...buildEntityBookScopeClauses(this.db, scope))); + return row?.count ?? 0; + } + const [row] = await this.db .select({ count: count() }) .from(bookSeriesMemberships) @@ -275,14 +286,20 @@ export class SeriesStrategy implements EntityStrategy { return row?.count ?? 0; } - async getBookTitles(id: number | string, limit: number): Promise { - const rows = await this.db - .select({ title: sql`COALESCE(${bookMetadata.title}, 'Untitled')` }) - .from(bookSeriesMemberships) - .innerJoin(bookMetadata, eq(bookMetadata.bookId, bookSeriesMemberships.bookId)) - .where(eq(bookSeriesMemberships.seriesId, Number(id))) - .orderBy(asc(bookMetadata.title)) - .limit(limit); + async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise { + const baseQuery = this.db.select({ title: sql`COALESCE(${bookMetadata.title}, 'Untitled')` }).from(bookSeriesMemberships); + const rows = scope + ? await baseQuery + .innerJoin(books, eq(books.id, bookSeriesMemberships.bookId)) + .innerJoin(bookMetadata, eq(bookMetadata.bookId, bookSeriesMemberships.bookId)) + .where(and(eq(bookSeriesMemberships.seriesId, Number(id)), ...buildEntityBookScopeClauses(this.db, scope))) + .orderBy(asc(bookMetadata.title)) + .limit(limit) + : await baseQuery + .innerJoin(bookMetadata, eq(bookMetadata.bookId, bookSeriesMemberships.bookId)) + .where(eq(bookSeriesMemberships.seriesId, Number(id))) + .orderBy(asc(bookMetadata.title)) + .limit(limit); return rows.map((row) => row.title); }