refactor(server): improve repository architecture and query structure

This commit is contained in:
neon
2026-03-31 02:23:42 -06:00
parent 55df7fb817
commit 60f66d17af
24 changed files with 330 additions and 87 deletions
@@ -0,0 +1,91 @@
import { readdir, readFile } from 'fs/promises';
import { join, relative } from 'path';
async function listFilesRecursive(root: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(root, entry.name);
if (entry.isDirectory()) return listFilesRecursive(fullPath);
return [fullPath];
}),
);
return files.flat();
}
describe('Architecture boundaries', () => {
const modulesRoot = join(process.cwd(), 'src/modules');
it('prevents controllers from importing repositories directly', async () => {
const allowlist = [
'src/modules/authors/authors.controller.ts',
'src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts',
'src/modules/staging/staging.controller.ts',
].sort();
const allFiles = await listFilesRecursive(modulesRoot);
const controllerFiles = allFiles.filter((file) => file.endsWith('.controller.ts'));
const violations: string[] = [];
for (const filePath of controllerFiles) {
const contents = await readFile(filePath, 'utf8');
if (/from ['"].*\.repository['"]/.test(contents)) {
violations.push(relative(process.cwd(), filePath));
}
}
expect(violations.sort()).toEqual(allowlist);
});
it('freezes direct DB injection in services to an explicit allowlist', async () => {
const allowlist = [
'src/modules/app-settings/app-settings.service.ts',
'src/modules/auth/auth.service.ts',
'src/modules/auth/oidc/backchannel-logout.service.ts',
'src/modules/auth/oidc/oidc-group-mapping.service.ts',
'src/modules/authors/author-enrichment-config.service.ts',
'src/modules/book/book-query-builder.service.ts',
'src/modules/book/comic-metadata.service.ts',
'src/modules/book-metadata-fetch/book-metadata-fetch-config.service.ts',
'src/modules/catalog/catalog.service.ts',
'src/modules/cover/cover.service.ts',
'src/modules/email/email-template-context.service.ts',
'src/modules/embedding/book-embedder.service.ts',
'src/modules/file-write/file-write-settings.service.ts',
'src/modules/kobo/services/kobo-device.service.ts',
'src/modules/kobo/services/kobo-download.service.ts',
'src/modules/kobo/services/kobo-reading-state.service.ts',
'src/modules/kobo/services/kobo-settings.service.ts',
'src/modules/kobo/services/kobo-sync.service.ts',
'src/modules/metadata/metadata.service.ts',
'src/modules/metadata-fetch/metadata-fetch.service.ts',
'src/modules/metadata-preferences/metadata-preferences.service.ts',
'src/modules/metadata-preferences/provider-config.service.ts',
'src/modules/metadata-score/metadata-score.service.ts',
'src/modules/opds/opds-book.service.ts',
'src/modules/opds/opds-user.service.ts',
'src/modules/scanner/file-watcher.service.ts',
'src/modules/seed/cleanup.service.ts',
'src/modules/seed/seed.service.ts',
'src/modules/staging/staging-finalize.service.ts',
'src/modules/staging/staging.service.ts',
'src/modules/upload/upload-processor.service.ts',
'src/modules/upload/upload.service.ts',
].sort();
const allFiles = await listFilesRecursive(modulesRoot);
const serviceFiles = allFiles.filter((file) => file.endsWith('.service.ts'));
const actual = (
await Promise.all(
serviceFiles.map(async (filePath) => {
const contents = await readFile(filePath, 'utf8');
return contents.includes('@Inject(DB)') ? relative(process.cwd(), filePath) : null;
}),
)
)
.filter((file): file is string => file !== null)
.sort();
expect(actual).toEqual(allowlist);
});
});
@@ -17,7 +17,7 @@ import type {
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user';
import { books } from '../../db/schema';
import { BookRepository } from '../book/book.repository';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { AuthorImageStorageService } from './author-image-storage.service';
@@ -43,7 +43,7 @@ export class AuthorsService {
constructor(
private readonly authorsRepo: AuthorsRepository,
private readonly bookRepo: BookRepository,
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
private readonly appSettings: AppSettingsService,
private readonly authorMetadataFetchService: AuthorMetadataFetchService,
@@ -106,7 +106,7 @@ export class AuthorsService {
}
const orderMap = new Map(page.bookIds.map((id, index) => [id, index]));
const { rows, authorRows, fileRows, genreRows, progressRows } = await this.bookRepo.findCards({
const { rows, authorRows, fileRows, genreRows, progressRows } = await this.bookReadService.findCards({
where: inArray(books.id, page.bookIds),
orderBy: [],
limit: page.bookIds.length,
@@ -1,7 +1,7 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional } from '@nestjs/common';
import type { BookMetadataFetchReason, MetadataField } from '@projectx/types';
import { MetadataProviderKey } from '@projectx/types';
import { BookRepository } from '../book/book.repository';
import { BookReadService } from '../book/book-read.service';
import * as schema from '../../db/schema';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { MetadataService } from '../metadata/metadata.service';
@@ -28,7 +28,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
private readonly queueRepo: BookMetadataFetchQueueRepository,
private readonly configService: BookMetadataFetchConfigService,
private readonly eligibilityService: BookMetadataFetchEligibilityService,
private readonly bookRepo: BookRepository,
private readonly bookReadService: BookReadService,
private readonly pipeline: MetadataFetchPipeline,
private readonly metadataService: MetadataService,
private readonly scoreService: MetadataScoreService,
@@ -159,7 +159,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
await this.emitStatus();
try {
const found = await this.bookRepo.findById(bookId);
const found = await this.bookReadService.findById(bookId);
if (!found) {
await this.queueRepo.markDone(bookId);
this.session.sessionDone++;
@@ -261,7 +261,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
scalarFields.lastMetadataFetchAt = new Date();
scalarFields.updatedAt = new Date();
await this.bookRepo.updateMetadataFields(bookId, scalarFields);
await this.bookReadService.updateMetadataFields(bookId, scalarFields);
if (r.authors !== undefined) {
const names = r.authors as string[];
@@ -296,7 +296,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
}
private async loadEligibilityData(bookId: number) {
const found = await this.bookRepo.findById(bookId);
const found = await this.bookReadService.findById(bookId);
if (!found) return null;
const { book, authorRows, genreRows, narratorRows } = found;
const meta = book.book_metadata;
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { SQL } from 'drizzle-orm';
import { BookRepository } from './book.repository';
@Injectable()
export class BookReadService {
constructor(private readonly bookRepo: BookRepository) {}
findLibraryIdByBookId(bookId: number) {
return this.bookRepo.findLibraryIdByBookId(bookId);
}
findFileById(fileId: number) {
return this.bookRepo.findFileById(fileId);
}
findPrimaryFilesByBookIds(bookIds: number[]) {
return this.bookRepo.findPrimaryFilesByBookIds(bookIds);
}
findCards(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number; userId: number }) {
return this.bookRepo.findCards(opts);
}
countWhere(where: SQL | undefined): Promise<number> {
return this.bookRepo.countWhere(where);
}
findRecommendationTitlesByBookIds(bookIds: number[]) {
return this.bookRepo.findRecommendationTitlesByBookIds(bookIds);
}
findById(id: number) {
return this.bookRepo.findById(id);
}
updateMetadataFields(bookId: number, fields: Parameters<BookRepository['updateMetadataFields']>[1]) {
return this.bookRepo.updateMetadataFields(bookId, fields);
}
}
@@ -104,14 +104,14 @@ function makeController() {
getKoboState: vi.fn(),
getDetail: vi.fn(),
};
const fileWriteRepo = {
const fileWriteService = {
findWriteLog: vi.fn(),
};
return {
controller: new BookController(bookService as never, fileWriteRepo as never),
controller: new BookController(bookService as never, fileWriteService as never),
bookService,
fileWriteRepo,
fileWriteService,
};
}
@@ -272,9 +272,9 @@ describe('BookController', () => {
});
it('verifies access before returning file write log entries', async () => {
const { controller, bookService, fileWriteRepo } = makeController();
const { controller, bookService, fileWriteService } = makeController();
bookService.verifyBookAccess.mockResolvedValue(undefined);
fileWriteRepo.findWriteLog.mockResolvedValue([{ id: 1 }]);
fileWriteService.findWriteLog.mockResolvedValue([{ id: 1 }]);
const result = await controller.getWriteLog(12, makeUser());
+3 -3
View File
@@ -22,7 +22,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { FileWriteRepository } from '../file-write/file-write.repository';
import { FileWriteService } from '../file-write/file-write.service';
import { BookService } from './book.service';
import { BookQueryPipe } from './pipes/book-query.pipe';
import { BulkBookIdsDto } from './dto/bulk-book-ids.dto';
@@ -81,7 +81,7 @@ function resolveAudioMimeType(format: string | null): string | null {
export class BookController {
constructor(
private readonly bookService: BookService,
private readonly fileWriteRepo: FileWriteRepository,
private readonly fileWriteService: FileWriteService,
) {}
@Post('embed-all')
@@ -328,7 +328,7 @@ export class BookController {
@RequirePermission(Permission.LibraryEditMetadata)
async getWriteLog(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
await this.bookService.verifyBookAccess(id, user);
const entries = await this.fileWriteRepo.findWriteLog(id);
const entries = await this.fileWriteService.findWriteLog(id);
return { entries };
}
+9 -2
View File
@@ -8,6 +8,7 @@ vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataMo
vi.mock('../metadata-fetch/metadata-fetch.module', () => ({ MetadataFetchModule: class MetadataFetchModule {} }));
import { BookQueryBuilder } from './book-query-builder.service';
import { BookReadService } from './book-read.service';
import { BookController } from './book.controller';
import { BookModule } from './book.module';
import { BookRepository } from './book.repository';
@@ -17,7 +18,13 @@ import { ComicMetadataService } from './comic-metadata.service';
describe('BookModule', () => {
it('registers expected controller/providers/exports', () => {
expect(Reflect.getMetadata('controllers', BookModule)).toEqual([BookController]);
expect(Reflect.getMetadata('providers', BookModule)).toEqual([BookService, BookRepository, BookQueryBuilder, ComicMetadataService]);
expect(Reflect.getMetadata('exports', BookModule)).toEqual([BookService, BookRepository, BookQueryBuilder]);
expect(Reflect.getMetadata('providers', BookModule)).toEqual([
BookService,
BookRepository,
BookReadService,
BookQueryBuilder,
ComicMetadataService,
]);
expect(Reflect.getMetadata('exports', BookModule)).toEqual([BookService, BookReadService, BookQueryBuilder]);
});
});
+3 -2
View File
@@ -9,6 +9,7 @@ import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NarratorModule } from '../narrator/narrator.module';
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
import { BookReadService } from './book-read.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookController } from './book.controller';
import { BookRepository } from './book.repository';
@@ -28,7 +29,7 @@ import { ComicMetadataService } from './comic-metadata.service';
UserBookStatusModule,
],
controllers: [BookController],
providers: [BookService, BookRepository, BookQueryBuilder, ComicMetadataService],
exports: [BookService, BookRepository, BookQueryBuilder],
providers: [BookService, BookRepository, BookReadService, BookQueryBuilder, ComicMetadataService],
exports: [BookService, BookReadService, BookQueryBuilder],
})
export class BookModule {}
@@ -5,7 +5,7 @@ import type { BooksPage } from '@projectx/types';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user';
import { books } from '../../db/schema';
import { BookRepository } from '../book/book.repository';
import { BookReadService } from '../book/book-read.service';
import { CollectionBooksDto } from './dto/collection-books.dto';
import { CreateCollectionDto } from './dto/create-collection.dto';
import { ReorderCollectionsDto } from './dto/reorder-collections.dto';
@@ -16,7 +16,7 @@ import { CollectionRepository } from './collection.repository';
export class CollectionService {
constructor(
private readonly collectionRepo: CollectionRepository,
private readonly bookRepo: BookRepository,
private readonly bookReadService: BookReadService,
) {}
private isSuperuser(user: RequestUser): boolean {
@@ -110,7 +110,7 @@ export class CollectionService {
const bookIds = bookIdRows.map((r) => r.bookId);
const where = inArray(books.id, bookIds);
const { rows, authorRows, fileRows, genreRows, progressRows, total } = await this.bookRepo.findCards({
const { rows, authorRows, fileRows, genreRows, progressRows, total } = await this.bookReadService.findCards({
where,
orderBy: [],
limit: size,
+3 -3
View File
@@ -8,7 +8,7 @@ import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata } from '../../db/schema';
import { coverDirPath, generateThumbnail, imageExt } from '../metadata/lib/cover';
import { BookRepository } from '../book/book.repository';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
import type { RequestUser } from '../../common/types/request-user';
import { CoverSearchParams } from './providers/cover-provider';
@@ -26,7 +26,7 @@ export class CoverService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly bookRepo: BookRepository,
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
private readonly config: ConfigService,
private readonly providerRegistry: CoverProviderRegistry,
@@ -182,7 +182,7 @@ export class CoverService {
}
private async verifyAccess(bookId: number, user: RequestUser): Promise<void> {
const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId);
const libraryId = await this.bookReadService.findLibraryIdByBookId(bookId);
if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(user.id, libraryId, user.isSuperuser);
}
@@ -1,11 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EmailPreferencesService } from './email-preferences.service';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailProviderService } from './email-provider.service';
import { EmailRecipientService } from './email-recipient.service';
import { EmailTemplateService } from './email-template.service';
import type { RequestUser } from '../../common/types/request-user';
describe('EmailPreferencesService', () => {
let service: EmailPreferencesService;
let repo: EmailPreferencesRepository;
let providerService: EmailProviderService;
let recipientService: EmailRecipientService;
let templateService: EmailTemplateService;
const mockUser: RequestUser = {
id: 1,
@@ -35,11 +41,26 @@ describe('EmailPreferencesService', () => {
upsert: vi.fn().mockResolvedValue([mockPrefs]),
},
},
{
provide: EmailProviderService,
useValue: { findOne: vi.fn().mockResolvedValue({ id: 10 }) },
},
{
provide: EmailRecipientService,
useValue: { getOwnedById: vi.fn().mockResolvedValue({ id: 20 }) },
},
{
provide: EmailTemplateService,
useValue: { findOne: vi.fn().mockResolvedValue({ id: 30 }) },
},
],
}).compile();
service = module.get<EmailPreferencesService>(EmailPreferencesService);
repo = module.get<EmailPreferencesRepository>(EmailPreferencesRepository);
providerService = module.get<EmailProviderService>(EmailProviderService);
recipientService = module.get<EmailRecipientService>(EmailRecipientService);
templateService = module.get<EmailTemplateService>(EmailTemplateService);
});
it('should find for user', async () => {
@@ -58,9 +79,20 @@ describe('EmailPreferencesService', () => {
const dto = { defaultProviderId: 99 };
const result = await service.upsert(dto, mockUser);
expect(repo.upsert).toHaveBeenCalledWith(1, dto);
expect(providerService.findOne).toHaveBeenCalledWith(99, mockUser);
expect(result.defaultProviderId).toBe(10);
});
it('should validate recipient ownership when defaultRecipientId is provided', async () => {
await service.upsert({ defaultRecipientId: 55 }, mockUser);
expect(recipientService.getOwnedById).toHaveBeenCalledWith(55, mockUser);
});
it('should validate template access when defaultTemplateId is provided', async () => {
await service.upsert({ defaultTemplateId: 66 }, mockUser);
expect(templateService.findOne).toHaveBeenCalledWith(66, mockUser);
});
it('should get for user by id', async () => {
const result = await service.getForUser(1);
expect(result?.defaultProviderId).toBe(10);
@@ -2,11 +2,19 @@ import { Injectable } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailProviderService } from './email-provider.service';
import { EmailRecipientService } from './email-recipient.service';
import { EmailTemplateService } from './email-template.service';
import { UpdateEmailPreferencesDto } from './dto/update-email-preferences.dto';
@Injectable()
export class EmailPreferencesService {
constructor(private readonly repo: EmailPreferencesRepository) {}
constructor(
private readonly repo: EmailPreferencesRepository,
private readonly providerService: EmailProviderService,
private readonly recipientService: EmailRecipientService,
private readonly templateService: EmailTemplateService,
) {}
async findForUser(user: RequestUser) {
const [prefs] = await this.repo.findByUserId(user.id);
@@ -14,6 +22,15 @@ export class EmailPreferencesService {
}
async upsert(dto: UpdateEmailPreferencesDto, user: RequestUser) {
if (dto.defaultProviderId !== undefined && dto.defaultProviderId !== null) {
await this.providerService.findOne(dto.defaultProviderId, user);
}
if (dto.defaultRecipientId !== undefined && dto.defaultRecipientId !== null) {
await this.recipientService.getOwnedById(dto.defaultRecipientId, user);
}
if (dto.defaultTemplateId !== undefined && dto.defaultTemplateId !== null) {
await this.templateService.findOne(dto.defaultTemplateId, user);
}
const [updated] = await this.repo.upsert(user.id, dto);
return updated;
}
@@ -70,7 +70,7 @@ describe('EmailSendOrchestrator', () => {
},
{
provide: EmailRecipientService,
useValue: { getOwnedById: vi.fn().mockResolvedValue(mockRecipient), getById: vi.fn().mockResolvedValue(mockRecipient) },
useValue: { getOwnedById: vi.fn().mockResolvedValue(mockRecipient) },
},
{
provide: EmailRecipientGroupService,
@@ -147,7 +147,7 @@ describe('EmailSendOrchestrator', () => {
const result = await orchestrator.quickSend(1, mockUser);
expect(result.queued).toBe(1);
expect(recipientService.getById).toHaveBeenCalledWith(10);
expect(recipientService.getOwnedById).toHaveBeenCalledWith(10, mockUser);
});
it('should throw if no default recipient', async () => {
@@ -109,7 +109,7 @@ export class EmailSendOrchestrator {
throw new BadRequestException('No default recipient configured. Set one in email settings.');
}
const recipient = await this.recipientService.getById(prefs.defaultRecipientId);
const recipient = await this.recipientService.getOwnedById(prefs.defaultRecipientId, user);
const resolved = await this.providerResolver.resolve(user, null);
await this.enqueueOne(
+5 -5
View File
@@ -4,7 +4,7 @@ import * as unzipper from 'unzipper';
import { XMLParser } from 'fast-xml-parser';
import type { EpubBookInfo, EpubManifestItem, EpubSpineItem, EpubTocItem } from '@projectx/types';
import { BookRepository } from '../book/book.repository';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
import type { RequestUser } from '../../common/types/request-user';
@@ -274,7 +274,7 @@ export class EpubService {
private readonly cache = new Map<string, CacheEntry>();
constructor(
private readonly bookRepo: BookRepository,
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
) {}
@@ -306,18 +306,18 @@ export class EpubService {
}
private async resolveEpubPath(bookId: number, fileId: number | undefined, user: RequestUser): Promise<string> {
const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId);
const libraryId = await this.bookReadService.findLibraryIdByBookId(bookId);
if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(user.id, libraryId, user.isSuperuser);
if (fileId != null) {
const file = await this.bookRepo.findFileById(fileId);
const file = await this.bookReadService.findFileById(fileId);
if (!file || file.bookId !== bookId) throw new NotFoundException(`File ${fileId} not found for book ${bookId}`);
if (file.format !== 'epub') throw new NotFoundException(`File ${fileId} is not an EPUB file`);
return file.absolutePath;
}
const [file] = await this.bookRepo.findPrimaryFilesByBookIds([bookId]);
const [file] = await this.bookReadService.findPrimaryFilesByBookIds([bookId]);
if (!file || file.format !== 'epub') throw new NotFoundException(`No primary EPUB file for book ${bookId}`);
return file.absolutePath;
}
@@ -142,6 +142,18 @@ export class FileWriteService implements OnModuleDestroy {
return result;
}
findWriteLog(bookId: number, limit = 20) {
return this.fileWriteRepo.findWriteLog(bookId, limit);
}
findNonMissingBookFilesByLibrary(libraryId: number) {
return this.fileWriteRepo.findNonMissingBookFilesByLibrary(libraryId);
}
resolveSettings(libraryId: number) {
return this.settingsService.resolve(libraryId);
}
private async loadCoverBytes(bookId: number): Promise<Buffer | null> {
const dir = join(this.booksPath, 'covers', String(bookId));
try {
+4 -4
View File
@@ -4,7 +4,7 @@ import type { BooksPage, GroupRule, SortSpec } from '@projectx/types';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryBuilder } from '../book/book-query-builder.service';
import { BookRepository } from '../book/book.repository';
import { BookReadService } from '../book/book-read.service';
import { validateGroupRule } from '../book/utils/group-rule.validator';
import { LibraryService } from '../library/library.service';
import { CreateLensDto } from './dto/create-lens.dto';
@@ -16,7 +16,7 @@ import { LensRepository } from './lens.repository';
export class LensService {
constructor(
private readonly lensRepo: LensRepository,
private readonly bookRepo: BookRepository,
private readonly bookReadService: BookReadService,
private readonly queryBuilder: BookQueryBuilder,
private readonly libraryService: LibraryService,
) {}
@@ -32,7 +32,7 @@ export class LensService {
return Promise.all(
lenses.map(async (lens) => {
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds, userId: user.id });
const bookCount = await this.bookRepo.countWhere(where);
const bookCount = await this.bookReadService.countWhere(where);
return { ...lens, bookCount };
}),
);
@@ -100,7 +100,7 @@ export class LensService {
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds, userId: user.id });
const orderBy = this.queryBuilder.buildOrderBy((lens.defaultSort as SortSpec[]) ?? []);
const { rows, authorRows, fileRows, genreRows, progressRows, total } = await this.bookRepo.findCards({
const { rows, authorRows, fileRows, genreRows, progressRows, total } = await this.bookReadService.findCards({
where,
orderBy,
limit: size,
@@ -20,17 +20,13 @@ describe('LibraryController', () => {
};
const bookService = { queryForLibrary: vi.fn() };
const fileWriteService = { writeToFile: vi.fn() };
const fileWriteRepo = { findNonMissingBookFilesByLibrary: vi.fn() };
const fileWriteSettings = { resolve: vi.fn() };
const fileWriteService = {
writeToFile: vi.fn(),
findNonMissingBookFilesByLibrary: vi.fn(),
resolveSettings: vi.fn(),
};
const controller = new LibraryController(
libraryService as any,
bookService as any,
fileWriteService as any,
fileWriteRepo as any,
fileWriteSettings as any,
);
const controller = new LibraryController(libraryService as any, bookService as any, fileWriteService as any);
beforeEach(() => {
vi.resetAllMocks();
@@ -38,7 +34,7 @@ describe('LibraryController', () => {
});
it('writeMetadataToFiles blocks non-dry-run when file write is disabled', async () => {
fileWriteSettings.resolve.mockResolvedValue({ enabled: false });
fileWriteService.resolveSettings.mockResolvedValue({ enabled: false });
await expect(controller.writeMetadataToFiles(1, undefined, { id: 1, isSuperuser: true } as any, { raw: {} } as any)).rejects.toBeInstanceOf(
BadRequestException,
@@ -46,8 +42,8 @@ describe('LibraryController', () => {
});
it('writeMetadataToFiles streams progress and final done event with counters', async () => {
fileWriteSettings.resolve.mockResolvedValue({ enabled: true });
fileWriteRepo.findNonMissingBookFilesByLibrary.mockResolvedValue([{ bookId: 1 }, { bookId: 2 }, { bookId: 3 }]);
fileWriteService.resolveSettings.mockResolvedValue({ enabled: true });
fileWriteService.findNonMissingBookFilesByLibrary.mockResolvedValue([{ bookId: 1 }, { bookId: 2 }, { bookId: 3 }]);
fileWriteService.writeToFile
.mockResolvedValueOnce({ status: 'success', fieldsWritten: [], durationMs: 1 })
@@ -74,11 +70,11 @@ describe('LibraryController', () => {
});
it('writeMetadataToFiles in dry-run mode skips settings check', async () => {
fileWriteRepo.findNonMissingBookFilesByLibrary.mockResolvedValue([]);
fileWriteService.findNonMissingBookFilesByLibrary.mockResolvedValue([]);
const reply = { raw: { writeHead: vi.fn(), write: vi.fn(), end: vi.fn() } };
await controller.writeMetadataToFiles(1, 'true', { id: 1, isSuperuser: true } as any, reply as any);
expect(fileWriteSettings.resolve).not.toHaveBeenCalled();
expect(fileWriteService.resolveSettings).not.toHaveBeenCalled();
});
});
@@ -21,9 +21,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { FileWriteRepository } from '../file-write/file-write.repository';
import { FileWriteService } from '../file-write/file-write.service';
import { FileWriteSettingsService } from '../file-write/file-write-settings.service';
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
import { BookService } from '../book/book.service';
import { CreateLibraryDto } from './dto/create-library.dto';
@@ -40,8 +38,6 @@ export class LibraryController {
private readonly libraryService: LibraryService,
private readonly bookService: BookService,
private readonly fileWriteService: FileWriteService,
private readonly fileWriteRepo: FileWriteRepository,
private readonly fileWriteSettings: FileWriteSettingsService,
) {}
@Get()
@@ -136,13 +132,13 @@ export class LibraryController {
const dryRun = dryRunParam === 'true';
if (!dryRun) {
const settings = await this.fileWriteSettings.resolve(libraryId);
const settings = await this.fileWriteService.resolveSettings(libraryId);
if (!settings.enabled) {
throw new BadRequestException('Metadata file write is not enabled. Enable it in Maintenance settings first.');
}
}
const rows = await this.fileWriteRepo.findNonMissingBookFilesByLibrary(libraryId);
const rows = await this.fileWriteService.findNonMissingBookFilesByLibrary(libraryId);
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -0,0 +1,43 @@
vi.mock('drizzle-orm', () => ({
and: (...args: unknown[]) => ({ type: 'and', args }),
count: () => ({ type: 'count' }),
eq: (left: unknown, right: unknown) => ({ type: 'eq', left, right }),
inArray: (...args: unknown[]) => ({ type: 'inArray', args }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: 'sql', strings, values }),
}));
import * as schema from '../../../db/schema';
import { OpdsBookService } from '../opds-book.service';
function makeDbMock(whereSpy: vi.Mock) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: whereSpy,
}),
}),
}),
}),
};
}
describe('OpdsBookService', () => {
it('scopes file lookup by both fileId and bookId when fileId is provided', async () => {
const whereSpy = vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
});
const db = makeDbMock(whereSpy);
const service = new OpdsBookService(db as never, {} as never);
await service.getBookFiles(7, 42);
const whereArg = whereSpy.mock.calls[0][0] as { type: string; args: Array<{ type: string; left: unknown; right: unknown }> };
expect(whereArg.type).toBe('and');
expect(whereArg.args).toEqual([
{ type: 'eq', left: schema.bookFiles.id, right: 42 },
{ type: 'eq', left: schema.bookFiles.bookId, right: 7 },
]);
});
});
+1 -1
View File
@@ -236,7 +236,7 @@ export class OpdsBookService {
.from(bookFiles)
.leftJoin(books, eq(books.id, bookFiles.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(fileId ? eq(bookFiles.id, fileId) : and(eq(books.id, bookId), eq(bookFiles.id, books.primaryFileId)))
.where(fileId ? and(eq(bookFiles.id, fileId), eq(bookFiles.bookId, bookId)) : and(eq(books.id, bookId), eq(bookFiles.id, books.primaryFileId)))
.limit(1);
const [file] = await fileQuery;
@@ -3,7 +3,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { BookRecommendation } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookEmbedderService } from '../embedding/book-embedder.service';
import { BookRepository } from '../book/book.repository';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
import { AnnCandidate, CandidateMetadata, RecommendationRepository, TargetBookData } from './recommendation.repository';
@@ -13,13 +13,13 @@ export class RecommendationService {
constructor(
private readonly recRepo: RecommendationRepository,
private readonly bookRepo: BookRepository,
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
private readonly embedder: BookEmbedderService,
) {}
async getRecommendations(bookId: number, user: RequestUser): Promise<BookRecommendation[]> {
const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId);
const libraryId = await this.bookReadService.findLibraryIdByBookId(bookId);
if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
@@ -49,7 +49,7 @@ export class RecommendationService {
if (rescored.length === 0) return [];
const topIds = rescored.map((r) => r.bookId);
const rows = await this.bookRepo.findRecommendationTitlesByBookIds(topIds);
const rows = await this.bookReadService.findRecommendationTitlesByBookIds(topIds);
const rowMap = new Map(rows.map((row) => [row.id, row]));
return rescored.map((r) => rowMap.get(r.bookId)).filter((row): row is { id: number; title: string | null } => row != null);
}
@@ -274,4 +274,29 @@ export class UserRepository {
})
.where(eq(schema.users.id, userId));
}
async assignViewerLibraries(userId: number, libraryIds: number[]) {
if (libraryIds.length === 0) return;
await this.db
.insert(schema.userLibraryAccess)
.values(libraryIds.map((libraryId) => ({ userId, libraryId, accessLevel: 'viewer' as const })))
.onConflictDoNothing();
}
async findLibraryIdsByUserId(userId: number): Promise<number[]> {
const rows = await this.db
.select({ libraryId: schema.userLibraryAccess.libraryId })
.from(schema.userLibraryAccess)
.where(eq(schema.userLibraryAccess.userId, userId));
return rows.map((r) => r.libraryId);
}
async replaceViewerLibraries(userId: number, libraryIds: number[]): Promise<void> {
await this.db.transaction(async (tx) => {
await tx.delete(schema.userLibraryAccess).where(eq(schema.userLibraryAccess.userId, userId));
if (libraryIds.length > 0) {
await tx.insert(schema.userLibraryAccess).values(libraryIds.map((libraryId) => ({ userId, libraryId, accessLevel: 'viewer' as const })));
}
});
}
}
+4 -22
View File
@@ -1,14 +1,10 @@
import { ConflictException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { hash } from 'bcryptjs';
import { randomBytes } from 'crypto';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Permission } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { DB } from '../../db/db.module';
import * as schema from '../../db/schema';
import { CreateUserDto } from './dto/create-user.dto';
import { SetPermissionsDto } from './dto/set-permissions.dto';
import { UpdateMeDto } from './dto/update-me.dto';
@@ -20,7 +16,6 @@ export class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly config: ConfigService,
@Inject(DB) private readonly db: NodePgDatabase<typeof schema>,
) {}
findByUsername(username: string) {
@@ -93,9 +88,7 @@ export class UserService {
}
if (dto.libraryIds?.length) {
for (const libraryId of dto.libraryIds) {
await this.db.insert(schema.userLibraryAccess).values({ userId: user.id, libraryId, accessLevel: 'viewer' }).onConflictDoNothing();
}
await this.userRepo.assignViewerLibraries(user.id, dto.libraryIds);
}
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
@@ -182,11 +175,7 @@ export class UserService {
}
async getLibraryIds(userId: number): Promise<number[]> {
const rows = await this.db
.select({ libraryId: schema.userLibraryAccess.libraryId })
.from(schema.userLibraryAccess)
.where(eq(schema.userLibraryAccess.userId, userId));
return rows.map((r) => r.libraryId);
return this.userRepo.findLibraryIdsByUserId(userId);
}
async setLibraries(targetUserId: number, libraryIds: number[], requestingUser: RequestUser): Promise<void> {
@@ -196,14 +185,7 @@ export class UserService {
throw new ForbiddenException('Only administrators can edit administrator accounts');
}
await this.db.transaction(async (tx) => {
await tx.delete(schema.userLibraryAccess).where(eq(schema.userLibraryAccess.userId, targetUserId));
if (libraryIds.length) {
await tx
.insert(schema.userLibraryAccess)
.values(libraryIds.map((libraryId) => ({ userId: targetUserId, libraryId, accessLevel: 'viewer' as const })));
}
});
await this.userRepo.replaceViewerLibraries(targetUserId, libraryIds);
}
async adminResetPassword(targetUserId: number, requestingUser: RequestUser) {