mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(library): filter entity duplicate detection by accessible libraries
Ensure entity manager strategies and duplicate computation restrict book references and candidate entities to libraries accessible by the current user. Closes #664
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
const BATCH_SIZE = 1000;
|
||||
|
||||
const ENTITY_BOOK_RELATIONS: Record<FirstClassEntityType, { table: string; entityIdColumn: string }> = {
|
||||
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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<EntityInfo> {
|
||||
async getEntityInfo(entityType: EntityType, user: RequestUser, entityId: number | string): Promise<EntityInfo> {
|
||||
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<string>; avgSimilarity: number }>,
|
||||
pairs: RawCandidatePair[],
|
||||
bookScope: EntityBookScope,
|
||||
): Promise<DuplicateCluster[]> {
|
||||
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 }
|
||||
|
||||
@@ -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<string, unknown> = {}, deps: Record<string, unk
|
||||
schedule: vi.fn().mockResolvedValue(undefined),
|
||||
...(deps.enrichmentOrchestrator as object | undefined),
|
||||
};
|
||||
return new AuthorStrategy(db as never, authorsRepo as never, authorImageStorage as never, enrichmentOrchestrator as never);
|
||||
const resolvedDb = { execute: vi.fn().mockResolvedValue({ rows: [] }), ...db };
|
||||
return new AuthorStrategy(resolvedDb as never, authorsRepo as never, authorImageStorage as never, enrichmentOrchestrator as never);
|
||||
}
|
||||
|
||||
function makeSelectChain(rows: unknown[]) {
|
||||
@@ -234,6 +235,16 @@ describe('AuthorStrategy', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -30,7 +30,9 @@ import type {
|
||||
StrategyMergeResult,
|
||||
StrategyRenameResult,
|
||||
StrategySplitResult,
|
||||
EntityBookScope,
|
||||
} from './entity-strategy.interface';
|
||||
import { assertEntityRelationsWithinLibraries, buildEntityBookScopeClauses } from './entity-book-scope';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -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<StrategyRenameResult> {
|
||||
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<StrategyDeleteResult> {
|
||||
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<StrategySplitResult> {
|
||||
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<void> {
|
||||
return assertEntityRelationsWithinLibraries(this.db, 'book_authors', 'author_id', entityIds, libraryIds);
|
||||
}
|
||||
|
||||
async findAffectedBookIds(ids: (number | string)[]): Promise<number[]> {
|
||||
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<number> {
|
||||
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
|
||||
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<string[]> {
|
||||
async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ title: sql<string>`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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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<RawCandidatePair[]>;
|
||||
findCandidatePairs(libraryIds: number[], minSimilarity: number, contentFilters?: ContentFilterRules): Promise<RawCandidatePair[]>;
|
||||
|
||||
getAllEntityIds?(): Promise<number[]>;
|
||||
|
||||
@@ -95,7 +101,7 @@ export interface EntityStrategy {
|
||||
split(input: SplitInput): Promise<StrategySplitResult>;
|
||||
|
||||
findAffectedBookIds(ids: (number | string)[]): Promise<number[]>;
|
||||
getBookCount(id: number | string): Promise<number>;
|
||||
getBookTitles(id: number | string, limit: number): Promise<string[]>;
|
||||
getBookCount(id: number | string, scope?: EntityBookScope): Promise<number>;
|
||||
getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]>;
|
||||
findEntityById(id: number | string): Promise<{ id: number | string; name: string } | null>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ function makeStrategy(db: Record<string, unknown> = {}) {
|
||||
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<string, unknown>;
|
||||
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<unknown>) => 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();
|
||||
});
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
@@ -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<RawCandidatePair[]> {
|
||||
async findCandidatePairs(libraryIds: number[], minSimilarity: number, contentFilters?: ContentFilterRules): Promise<RawCandidatePair[]> {
|
||||
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<StrategyMergeResult> {
|
||||
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<number>();
|
||||
|
||||
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<StrategyRenameResult> {
|
||||
@@ -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<StrategyDeleteResult> {
|
||||
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<number> {
|
||||
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
|
||||
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<string[]> {
|
||||
async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
|
||||
const value = id as string;
|
||||
const rows = await this.db
|
||||
.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` })
|
||||
.from(bookMetadata)
|
||||
.where(eq(this.field, value))
|
||||
const query = this.db.select({ title: sql<string>`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);
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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 });
|
||||
|
||||
@@ -21,7 +21,9 @@ import type {
|
||||
StrategyMergeResult,
|
||||
StrategyRenameResult,
|
||||
StrategySplitResult,
|
||||
EntityBookScope,
|
||||
} from './entity-strategy.interface';
|
||||
import { assertEntityRelationsWithinLibraries, buildEntityBookScopeClauses } from './entity-book-scope';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -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<StrategyRenameResult> {
|
||||
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<StrategyDeleteResult> {
|
||||
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<StrategySplitResult> {
|
||||
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<number> {
|
||||
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
|
||||
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<string[]> {
|
||||
async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ title: sql<string>`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<string, unknown>;
|
||||
|
||||
private assertMutationScope(entityIds: number[], libraryIds: number[]): Promise<void> {
|
||||
return assertEntityRelationsWithinLibraries(this.db, this.rawJunctionTable, this.rawEntityIdCol, entityIds, libraryIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
@@ -49,13 +50,13 @@ export class NarratorStrategy extends JunctionEntityStrategy {
|
||||
};
|
||||
}
|
||||
|
||||
override async getBookTitles(id: number | string, limit: number): Promise<string[]> {
|
||||
override async getBookTitles(id: number | string, limit: number, scope?: EntityBookScope): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ title: sql<string>`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);
|
||||
|
||||
@@ -20,7 +20,9 @@ import type {
|
||||
StrategyMergeResult,
|
||||
StrategyRenameResult,
|
||||
StrategySplitResult,
|
||||
EntityBookScope,
|
||||
} from './entity-strategy.interface';
|
||||
import { buildEntityBookScopeClauses } from './entity-book-scope';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -267,7 +269,16 @@ export class SeriesStrategy implements EntityStrategy {
|
||||
return [...new Set(rows.map((row) => row.bookId))];
|
||||
}
|
||||
|
||||
async getBookCount(id: number | string): Promise<number> {
|
||||
async getBookCount(id: number | string, scope?: EntityBookScope): Promise<number> {
|
||||
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<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ title: sql<string>`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<string[]> {
|
||||
const baseQuery = this.db.select({ title: sql<string>`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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user