feat(library): filter dashboards by selected libraries

Let each user choose which accessible libraries feed dashboard shelves and reading widgets while preserving access checks and Smart Scope filters.

Closes #1113
This commit is contained in:
neon
2026-08-23 15:04:20 +00:00
parent 7668f46c42
commit a3bc499028
16 changed files with 541 additions and 80 deletions
@@ -0,0 +1,35 @@
import { makeUser } from '../../common/test-utils/make-user';
import { dashboardLibraryScopeCacheKey, resolveDashboardLibraryIds } from './dashboard-library-scope';
describe('dashboard library scope', () => {
it('uses every accessible library when no dashboard selection is stored', () => {
expect(resolveDashboardLibraryIds([3, 1], makeUser())).toEqual([3, 1]);
});
it('intersects the saved selection with accessible libraries and preserves accessible order', () => {
const user = makeUser({ settings: { dashboardConfig: { libraryIds: [9, 3, 9, 99] } } });
expect(resolveDashboardLibraryIds([1, 3, 9, 12], user)).toEqual([3, 9]);
});
it('returns no libraries when every saved selection has become inaccessible', () => {
const user = makeUser({ settings: { dashboardConfig: { libraryIds: [8] } } });
expect(resolveDashboardLibraryIds([1, 2], user)).toEqual([]);
});
it.each([undefined, null, 'invalid', [], [0, -1, 1.5, Number.NaN]])(
'treats a missing or unusable selection as all accessible libraries: %j',
(libraryIds) => {
const user = makeUser({ settings: { dashboardConfig: { libraryIds } } });
expect(resolveDashboardLibraryIds([4, 5], user)).toEqual([4, 5]);
},
);
it('builds a stable cache key from the resolved accessible intersection', () => {
expect(dashboardLibraryScopeCacheKey([7, 2])).toBe('2,7');
expect(dashboardLibraryScopeCacheKey([2, 7])).toBe('2,7');
expect(dashboardLibraryScopeCacheKey([])).toBe('none');
});
});
@@ -0,0 +1,26 @@
import type { DashboardConfig } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
function selectedDashboardLibraryIds(user: RequestUser): number[] | undefined {
const config = user.settings?.['dashboardConfig'];
if (!config || typeof config !== 'object' || Array.isArray(config)) return undefined;
const libraryIds = (config as DashboardConfig).libraryIds;
if (!Array.isArray(libraryIds) || libraryIds.length === 0) return undefined;
const selectedIds = [...new Set(libraryIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
return selectedIds.length > 0 ? selectedIds : undefined;
}
export function resolveDashboardLibraryIds(accessibleLibraryIds: readonly number[], user: RequestUser): number[] {
const selectedIds = selectedDashboardLibraryIds(user);
if (!selectedIds) return [...accessibleLibraryIds];
const selected = new Set(selectedIds);
return accessibleLibraryIds.filter((id) => selected.has(id));
}
export function dashboardLibraryScopeCacheKey(libraryIds: readonly number[]): string {
return libraryIds.length > 0 ? [...libraryIds].sort((a, b) => a - b).join(',') : 'none';
}
@@ -112,6 +112,50 @@ describe('DashboardWidgetService', () => {
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenCalledWith(7, [3, 5], EMPTY_CONTENT_FILTER_RULES);
expect(result).toEqual(mockData);
});
it('intersects widget queries with the saved dashboard library selection', async () => {
const { service, widgetRepo, libraryService } = makeService();
const user = makeUser({ id: 7, settings: { dashboardConfig: { libraryIds: [5, 99] } } });
libraryService.findAccessibleLibraryIds.mockResolvedValue([3, 5]);
widgetRepo.getCurrentlyReadingBooks.mockResolvedValue({ books: [] });
await service.getCurrentlyReading(user);
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenCalledWith(7, [5], EMPTY_CONTENT_FILTER_RULES);
});
it('does not reuse cached widget data after the dashboard library selection changes', async () => {
const { service, widgetRepo, libraryService } = makeService();
const firstUser = makeUser({ id: 7, settings: { dashboardConfig: { libraryIds: [3] } } });
const secondUser = makeUser({ id: 7, settings: { dashboardConfig: { libraryIds: [5] } } });
libraryService.findAccessibleLibraryIds.mockResolvedValue([3, 5]);
widgetRepo.getCurrentlyReadingBooks.mockResolvedValueOnce({ books: [{ bookId: 3 }] }).mockResolvedValueOnce({ books: [{ bookId: 5 }] });
const first = await service.getCurrentlyReading(firstUser);
const second = await service.getCurrentlyReading(secondUser);
expect(first.books).toEqual([{ bookId: 3 }]);
expect(second.books).toEqual([{ bookId: 5 }]);
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenCalledTimes(2);
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenNthCalledWith(1, 7, [3], EMPTY_CONTENT_FILTER_RULES);
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenNthCalledWith(2, 7, [5], EMPTY_CONTENT_FILTER_RULES);
});
it('does not reuse cached widget data after library access is revoked', async () => {
const { service, widgetRepo, libraryService } = makeService();
const user = makeUser({ id: 7, settings: { dashboardConfig: { libraryIds: [3, 5] } } });
libraryService.findAccessibleLibraryIds.mockResolvedValueOnce([3, 5]).mockResolvedValueOnce([3]);
widgetRepo.getCurrentlyReadingBooks.mockResolvedValueOnce({ books: [{ bookId: 5 }] }).mockResolvedValueOnce({ books: [] });
const beforeRevocation = await service.getCurrentlyReading(user);
const afterRevocation = await service.getCurrentlyReading(user);
expect(beforeRevocation.books).toEqual([{ bookId: 5 }]);
expect(afterRevocation.books).toEqual([]);
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenCalledTimes(2);
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenNthCalledWith(1, 7, [3, 5], EMPTY_CONTENT_FILTER_RULES);
expect(widgetRepo.getCurrentlyReadingBooks).toHaveBeenNthCalledWith(2, 7, [3], EMPTY_CONTENT_FILTER_RULES);
});
});
describe('getReadingStreak', () => {
@@ -37,6 +37,7 @@ import {
selectChallenge,
} from './dashboard-widget.calculations';
import { DashboardWidgetRepository } from './dashboard-widget.repository';
import { dashboardLibraryScopeCacheKey, resolveDashboardLibraryIds } from './dashboard-library-scope';
const DASHBOARD_LIVE_TTL_MS = 120_000;
const DASHBOARD_STALE_TTL_MS = 300_000;
@@ -59,13 +60,21 @@ export class DashboardWidgetService {
return user.isSuperuser ? undefined : user.contentFilters;
}
private cacheOwnerKey(user: RequestUser, libraryIds: readonly number[]): string {
return `${user.id}:${dashboardLibraryScopeCacheKey(libraryIds)}`;
}
private async getLibraryIds(user: RequestUser): Promise<number[]> {
return resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
}
async getReadingGoal(user: RequestUser): Promise<ReadingGoalWidgetData> {
const settings = user.settings as UserSettings | undefined;
const goalBooks = settings?.dashboardConfig?.readingGoal ?? null;
const year = new Date().getUTCFullYear();
const completedBooks = await this.staleCache.get(String(user.id), `reading-goal-completed:${year}`, async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
const completedBooks = await this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), `reading-goal-completed:${year}`, async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getCompletedBooksThisYear(user.id, accessibleLibraryIds, contentFilters);
});
@@ -74,32 +83,32 @@ export class DashboardWidgetService {
}
async getCurrentlyReading(user: RequestUser): Promise<CurrentlyReadingWidgetData> {
return this.liveCache.get(String(user.id), 'currently-reading', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'currently-reading', async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getCurrentlyReadingBooks(user.id, accessibleLibraryIds, contentFilters);
});
}
async getReadingStreak(user: RequestUser): Promise<ReadingStreakWidgetData> {
return this.liveCache.get(String(user.id), 'reading-streak', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'reading-streak', async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getReadingStreak(user.id, accessibleLibraryIds, contentFilters);
});
}
async getLibraryOverview(user: RequestUser): Promise<LibraryOverviewWidgetData> {
return this.staleCache.get(String(user.id), 'library-overview', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'library-overview', async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getLibraryOverview(accessibleLibraryIds, contentFilters);
});
}
async getHighlightOfTheDay(user: RequestUser): Promise<HighlightOfTheDayWidgetData | null> {
return this.liveCache.get(String(user.id), 'highlight-of-the-day', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'highlight-of-the-day', async () => {
const contentFilters = this.getContentFilters(user);
const total = await this.widgetRepo.getAnnotationCount(user.id, accessibleLibraryIds, contentFilters);
if (total === 0) return null;
@@ -110,8 +119,8 @@ export class DashboardWidgetService {
}
async getMonthlyChallenge(user: RequestUser): Promise<MonthlyChallengeWidgetData> {
return this.staleCache.get(String(user.id), 'monthly-challenge', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'monthly-challenge', async () => {
const contentFilters = this.getContentFilters(user);
const today = new Date();
const year = today.getUTCFullYear();
@@ -143,8 +152,8 @@ export class DashboardWidgetService {
}
async getYearProjection(user: RequestUser): Promise<YearProjectionWidgetData> {
return this.staleCache.get(String(user.id), 'year-projection', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'year-projection', async () => {
const contentFilters = this.getContentFilters(user);
const today = new Date();
const year = today.getUTCFullYear();
@@ -168,16 +177,16 @@ export class DashboardWidgetService {
}
async getNeglectedGems(user: RequestUser): Promise<NeglectedGemsWidgetData> {
return this.staleCache.get(String(user.id), 'neglected-gems', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'neglected-gems', async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getNeglectedGems(user.id, accessibleLibraryIds, new Date(), contentFilters);
});
}
async getReadingDna(user: RequestUser): Promise<ReadingDnaWidgetData> {
return this.staleCache.get(String(user.id), 'reading-dna', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'reading-dna', async () => {
const contentFilters = this.getContentFilters(user);
const since = new Date();
since.setUTCMonth(since.getUTCMonth() - 6);
@@ -187,16 +196,16 @@ export class DashboardWidgetService {
}
async getLongWait(user: RequestUser): Promise<LongWaitWidgetData | null> {
return this.staleCache.get(String(user.id), 'long-wait', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'long-wait', async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getLongWait(user.id, accessibleLibraryIds, new Date(), contentFilters);
});
}
async getDiversityScore(user: RequestUser): Promise<DiversityScoreWidgetData> {
return this.staleCache.get(String(user.id), 'diversity-score', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'diversity-score', async () => {
const contentFilters = this.getContentFilters(user);
const data = await this.widgetRepo.getDiversityData(user.id, accessibleLibraryIds, contentFilters);
return computeDiversityScore(
@@ -211,8 +220,8 @@ export class DashboardWidgetService {
}
async getReadingRhythm(user: RequestUser): Promise<ReadingRhythmWidgetData> {
return this.liveCache.get(String(user.id), 'reading-rhythm', async () => {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'reading-rhythm', async () => {
const contentFilters = this.getContentFilters(user);
const today = new Date();
const since = new Date(today);
@@ -91,17 +91,30 @@ describe('DashboardService', () => {
});
it('executes smartScope scroller with max limit clamp and returns smartScope items', async () => {
const { service, smartScopeService } = makeService();
const user = makeUser({ id: 7 });
const { service, smartScopeService, libraryService } = makeService();
const user = makeUser({ id: 7, settings: { dashboardConfig: { libraryIds: [12] } } });
const items = [{ id: 11 }, { id: 12 }];
libraryService.findAccessibleLibraryIds.mockResolvedValue([10, 12]);
smartScopeService.executeSmartScope.mockResolvedValue({ items, total: 2, page: 0, size: 50 });
const result = await service.getScroller(ScrollerType.SMART_SCOPE, user, 999, 88);
expect(smartScopeService.executeSmartScope).toHaveBeenCalledWith(88, user, 0, 50);
expect(smartScopeService.executeSmartScope).toHaveBeenCalledWith(88, user, 0, 50, undefined, [12]);
expect(result).toEqual(items);
});
it('intersects standard shelves with the saved dashboard library selection', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
const user = makeUser({ id: 5, settings: { dashboardConfig: { libraryIds: [200, 999] } } });
libraryService.findAccessibleLibraryIds.mockResolvedValue([100, 200]);
dashboardRepo.findRecentlyAddedBookIds.mockResolvedValue([9]);
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([9]));
await service.getScroller(ScrollerType.RECENTLY_ADDED, user, 20);
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([200], 20, EMPTY_CONTENT_FILTER_RULES);
});
it('returns empty list when user has no accessible libraries', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
@@ -255,6 +268,29 @@ describe('DashboardService', () => {
]);
});
it('uses one intersected library scope for every shelf in a batch, including smart scopes', async () => {
const { service, dashboardRepo, bookReadService, libraryService, smartScopeService } = makeService();
const user = makeUser({ id: 8, settings: { dashboardConfig: { libraryIds: [11] } } });
libraryService.findAccessibleLibraryIds.mockResolvedValue([10, 11]);
dashboardRepo.findRecentlyAddedBookIds.mockResolvedValue([]);
smartScopeService.executeSmartScopeBookIds.mockResolvedValue([]);
await service.getScrollers(
{
items: [
{ id: 'recent', type: 'recently-added', limit: 20 },
{ id: 'scope', type: 'smart-scope', limit: 20, smartScopeId: 7 },
],
},
user,
);
expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledOnce();
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([11], 20, EMPTY_CONTENT_FILTER_RULES);
expect(smartScopeService.executeSmartScopeBookIds).toHaveBeenCalledWith(7, user, 20, [11]);
expect(bookReadService.findCardsByBookIds).not.toHaveBeenCalled();
});
it('keeps successful shelves when one batched selection fails', async () => {
const { service, dashboardRepo, bookReadService, libraryService, smartScopeService } = makeService();
const user = makeUser({ id: 8 });
@@ -9,6 +9,7 @@ import { assembleBookCards } from '../book/utils/assemble-book-cards';
import { SmartScopeService } from '../smart-scope/smart-scope.service';
import { LibraryService } from '../library/library.service';
import { DashboardRepository } from './dashboard.repository';
import { resolveDashboardLibraryIds } from './dashboard-library-scope';
import { DASHBOARD_SCROLLER_MAX_LIMIT, type DashboardScrollerBatchDto, type DashboardScrollerBatchItemDto } from './dto/dashboard-scroller-batch.dto';
import { ScrollerType } from './dto/scroller-type.enum';
@@ -45,7 +46,7 @@ export class DashboardService {
`[dashboard.scroller_batch] [start] userId=${user.id} shelfCount=${dto.items.length} concurrency=${SCROLLER_QUERY_CONCURRENCY} - scroller batch started`,
);
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
const selections = await mapWithConcurrency(dto.items, SCROLLER_QUERY_CONCURRENCY, async (item) => {
const selectionStartedAt = Date.now();
try {
@@ -100,7 +101,7 @@ export class DashboardService {
let bookIds: number[];
if (item.type === ScrollerType.SMART_SCOPE) {
const smartScopeId = this.assertSmartScopeId(item.smartScopeId);
bookIds = await this.smartScopeService.executeSmartScopeBookIds(smartScopeId, user, item.limit);
bookIds = await this.smartScopeService.executeSmartScopeBookIds(smartScopeId, user, item.limit, accessibleLibraryIds);
} else {
bookIds = await this.findScrollerBookIdsForLibraries(item.type, user, item.limit, accessibleLibraryIds);
}
@@ -115,7 +116,9 @@ export class DashboardService {
const clampedLimit = Math.min(Math.max(1, limit), DASHBOARD_SCROLLER_MAX_LIMIT);
if (type === ScrollerType.SMART_SCOPE) {
const result = await this.smartScopeService.executeSmartScope(this.assertSmartScopeId(smartScopeId), user, 0, clampedLimit);
const resolvedSmartScopeId = this.assertSmartScopeId(smartScopeId);
const accessibleLibraryIds = resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
const result = await this.smartScopeService.executeSmartScope(resolvedSmartScopeId, user, 0, clampedLimit, undefined, accessibleLibraryIds);
return result.items;
}
@@ -129,11 +132,14 @@ export class DashboardService {
}
async getSmartScopeBookIds(smartScopeId: number | undefined, user: RequestUser, limit: number): Promise<number[]> {
const accessibleLibraryIds = resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
const result = await this.smartScopeService.executeSmartScope(
this.assertSmartScopeId(smartScopeId),
user,
0,
Math.min(Math.max(1, limit), DASHBOARD_SCROLLER_MAX_LIMIT),
undefined,
accessibleLibraryIds,
);
return result.items.map((item) => item.id);
}
@@ -146,7 +152,7 @@ export class DashboardService {
}
private async findScrollerBookIds(type: Exclude<ScrollerType, 'smart-scope'>, user: RequestUser, clampedLimit: number): Promise<number[]> {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const accessibleLibraryIds = resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
return this.findScrollerBookIdsForLibraries(type, user, clampedLimit, accessibleLibraryIds);
}
@@ -601,6 +601,29 @@ describe('SmartScopeService', () => {
expect(bookService.executeBooksQuery).not.toHaveBeenCalled();
});
it('executeSmartScopeBookIds intersects a caller-provided restriction with accessible libraries', async () => {
const { service, smartScopeRepo, libraryService, queryBuilder, bookService } = makeService();
const smartScope = makeSmartScope({
id: 5,
userId: 12,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'test' }] },
});
smartScopeRepo.findById.mockResolvedValue([smartScope]);
libraryService.findAccessibleLibraryIds.mockResolvedValue([4, 6]);
queryBuilder.buildWhere.mockReturnValue('where');
bookService.executeBookIdsQuery.mockResolvedValue([]);
await service.executeSmartScopeBookIds(5, makeUser({ id: 12 }), 20, [4, 99]);
expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledOnce();
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(smartScope.filter, {
accessibleLibraryIds: [4],
userId: 12,
timeZone: 'UTC',
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
});
it('executeSmartScope seeds sort from the smartScope when the request does not override it', async () => {
const { service, smartScopeRepo, libraryService, queryBuilder, bookService } = makeService();
const smartScope = makeSmartScope({
@@ -201,28 +201,40 @@ export class SmartScopeService {
}
}
async executeSmartScope(id: number, user: RequestUser, page: number, size: number, q?: string): Promise<BooksPage> {
return this.queryBooks(id, user, {
sort: [],
pagination: { page, size },
...(q?.trim() ? { q: q.trim() } : {}),
});
async executeSmartScope(
id: number,
user: RequestUser,
page: number,
size: number,
q?: string,
libraryIdsRestriction?: readonly number[],
): Promise<BooksPage> {
return this.queryBooks(
id,
user,
{
sort: [],
pagination: { page, size },
...(q?.trim() ? { q: q.trim() } : {}),
},
libraryIdsRestriction,
);
}
async executeSmartScopeBookIds(id: number, user: RequestUser, size: number): Promise<number[]> {
async executeSmartScopeBookIds(id: number, user: RequestUser, size: number, libraryIdsRestriction?: readonly number[]): Promise<number[]> {
const query: BookQuery = { sort: [], pagination: { page: 0, size } };
const prepared = await this.prepareBooksQuery(id, user, query);
const prepared = await this.prepareBooksQuery(id, user, query, libraryIdsRestriction);
if (!prepared) return [];
return this.bookService.executeBookIdsQuery(user.id, prepared.where, prepared.effectiveQuery);
}
async queryBooks(id: number, user: RequestUser, query: BookQuery): Promise<BooksPage> {
async queryBooks(id: number, user: RequestUser, query: BookQuery, libraryIdsRestriction?: readonly number[]): Promise<BooksPage> {
const start = Date.now();
this.logger.debug(
`[smart_scope.query_books] [start] scopeId=${id} userId=${user.id} page=${query.pagination.page} size=${query.pagination.size} - query started`,
);
const prepared = await this.prepareBooksQuery(id, user, query);
const prepared = await this.prepareBooksQuery(id, user, query, libraryIdsRestriction);
if (!prepared) {
return { items: [], total: 0, page: query.pagination.page, size: query.pagination.size };
}
@@ -265,6 +277,7 @@ export class SmartScopeService {
id: number,
user: RequestUser,
query: T,
libraryIdsRestriction?: readonly number[],
): Promise<{ where: SQL | undefined; effectiveQuery: T } | null> {
const smartScope = await this.getSmartScopeOrThrow(id);
this.assertReadAccess(smartScope, user);
@@ -273,6 +286,8 @@ export class SmartScopeService {
if (!scopeFilter) return null;
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const restriction = libraryIdsRestriction ? new Set(libraryIdsRestriction) : undefined;
const restrictedLibraryIds = restriction ? accessibleLibraryIds.filter((libraryId) => restriction.has(libraryId)) : accessibleLibraryIds;
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
const filter = this.combineFilters(scopeFilter, query.filter);
const effectiveQuery: T = {
@@ -281,7 +296,7 @@ export class SmartScopeService {
sort: this.resolveSort(query.sort, smartScope),
};
const where = this.queryBuilder.buildWhere(filter, {
accessibleLibraryIds,
accessibleLibraryIds: restrictedLibraryIds,
userId: user.id,
q: query.q,
timeZone,