mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user