mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
refactor(server): improve repository architecture and query structure
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 })));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user