mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(annotations): harden access validation and metadata fetch scheduling
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BookMetadataFetchConfigService, DEFAULT_BOOK_METADATA_FETCH_CONFIG } from './book-metadata-fetch-config.service';
|
||||
|
||||
describe('BookMetadataFetchConfigService', () => {
|
||||
const makeService = () => {
|
||||
const db = {
|
||||
query: {
|
||||
appSettings: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
libraries: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
db,
|
||||
service: new BookMetadataFetchConfigService(db as never),
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns defaults when global config is missing', async () => {
|
||||
const { db, service } = makeService();
|
||||
db.query.appSettings.findFirst.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(service.getGlobalConfig()).resolves.toEqual(DEFAULT_BOOK_METADATA_FETCH_CONFIG);
|
||||
});
|
||||
|
||||
it('logs and falls back to defaults when stored global config is invalid JSON', async () => {
|
||||
const { db, service } = makeService();
|
||||
db.query.appSettings.findFirst.mockResolvedValueOnce({ value: '{"enabled":true' });
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await expect(service.getGlobalConfig()).resolves.toEqual(DEFAULT_BOOK_METADATA_FETCH_CONFIG);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('book.metadata_fetch.config_parse'));
|
||||
});
|
||||
|
||||
it('deep-merges a library override onto global config', async () => {
|
||||
const { db, service } = makeService();
|
||||
db.query.appSettings.findFirst.mockResolvedValueOnce({
|
||||
value: JSON.stringify({
|
||||
enabled: true,
|
||||
triggerOnImport: false,
|
||||
conditions: {
|
||||
neverFetched: { enabled: true },
|
||||
scoreThreshold: { enabled: true, threshold: 70 },
|
||||
missingFields: { enabled: true, fields: ['description'] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
db.query.libraries.findFirst.mockResolvedValueOnce({
|
||||
bookMetadataFetchConfig: {
|
||||
triggerOnImport: true,
|
||||
conditions: {
|
||||
scoreThreshold: { threshold: 50 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.getEffectiveConfig(42)).resolves.toEqual({
|
||||
enabled: true,
|
||||
triggerOnImport: true,
|
||||
conditions: {
|
||||
neverFetched: { enabled: true },
|
||||
scoreThreshold: { enabled: true, threshold: 50 },
|
||||
missingFields: { enabled: true, fields: ['description'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { BookMetadataFetchConfig, BookMetadataFetchConfigOverride, BookMetadataFetchLibraryConfig } from '@projectx/types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
@@ -26,11 +26,13 @@ export const DEFAULT_BOOK_METADATA_FETCH_CONFIG: BookMetadataFetchConfig = {
|
||||
|
||||
@Injectable()
|
||||
export class BookMetadataFetchConfigService {
|
||||
private readonly logger = new Logger(BookMetadataFetchConfigService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async getGlobalConfig(): Promise<BookMetadataFetchConfig> {
|
||||
const row = await this.db.query.appSettings.findFirst({ where: eq(appSettings.key, KEYS.CONFIG) });
|
||||
return parseSafe<BookMetadataFetchConfig>(row?.value, { ...DEFAULT_BOOK_METADATA_FETCH_CONFIG });
|
||||
return this.parseSafe<BookMetadataFetchConfig>(KEYS.CONFIG, row?.value, { ...DEFAULT_BOOK_METADATA_FETCH_CONFIG });
|
||||
}
|
||||
|
||||
async setGlobalConfig(config: BookMetadataFetchConfig): Promise<void> {
|
||||
@@ -96,13 +98,18 @@ export class BookMetadataFetchConfigService {
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSafe<T>(val: string | undefined, fallback: T): T {
|
||||
if (!val) return fallback;
|
||||
try {
|
||||
return JSON.parse(val) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
private parseSafe<T>(key: string, value: string | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error ? error.name : 'Error';
|
||||
const message = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
|
||||
this.logger.warn(
|
||||
`[book.metadata_fetch.config_parse] [fail] key=${key} errorClass=${errorClass} error="${message}" - failed to parse stored config`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import type { BookMetadataFetchConfig } from '@projectx/types';
|
||||
|
||||
import { BookMetadataFetchEligibilityService, type BookEligibilityData } from './book-metadata-fetch-eligibility.service';
|
||||
|
||||
const baseBook = (): BookEligibilityData => ({
|
||||
metadataScore: 80,
|
||||
lastMetadataFetchAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
title: 'Book',
|
||||
subtitle: 'Subtitle',
|
||||
description: 'Description',
|
||||
publisher: 'Publisher',
|
||||
publishedYear: 2020,
|
||||
language: 'en',
|
||||
pageCount: 320,
|
||||
seriesName: 'Series',
|
||||
seriesIndex: 1,
|
||||
coverSource: 'cover.jpg',
|
||||
hasAuthors: true,
|
||||
hasGenres: true,
|
||||
hasNarrators: true,
|
||||
durationSeconds: 10_000,
|
||||
abridged: false,
|
||||
});
|
||||
|
||||
const baseConfig = (): BookMetadataFetchConfig => ({
|
||||
enabled: true,
|
||||
triggerOnImport: true,
|
||||
conditions: {
|
||||
neverFetched: { enabled: false },
|
||||
scoreThreshold: { enabled: false, threshold: 60 },
|
||||
missingFields: { enabled: false, fields: [] },
|
||||
},
|
||||
});
|
||||
|
||||
describe('BookMetadataFetchEligibilityService', () => {
|
||||
let service: BookMetadataFetchEligibilityService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new BookMetadataFetchEligibilityService();
|
||||
});
|
||||
|
||||
it('returns true when never fetched condition matches', () => {
|
||||
const book = { ...baseBook(), lastMetadataFetchAt: null };
|
||||
const config = baseConfig();
|
||||
config.conditions.neverFetched.enabled = true;
|
||||
|
||||
expect(service.isEligible(book, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when score threshold is enabled and score is null', () => {
|
||||
const book = { ...baseBook(), metadataScore: null };
|
||||
const config = baseConfig();
|
||||
config.conditions.scoreThreshold.enabled = true;
|
||||
config.conditions.scoreThreshold.threshold = 70;
|
||||
|
||||
expect(service.isEligible(book, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when score threshold is enabled and score is below threshold', () => {
|
||||
const book = { ...baseBook(), metadataScore: 55 };
|
||||
const config = baseConfig();
|
||||
config.conditions.scoreThreshold.enabled = true;
|
||||
config.conditions.scoreThreshold.threshold = 60;
|
||||
|
||||
expect(service.isEligible(book, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when all enabled conditions do not match', () => {
|
||||
const book = baseBook();
|
||||
const config = baseConfig();
|
||||
config.conditions.neverFetched.enabled = true;
|
||||
config.conditions.scoreThreshold.enabled = true;
|
||||
config.conditions.missingFields.enabled = true;
|
||||
config.conditions.missingFields.fields = ['description', 'cover'];
|
||||
|
||||
expect(service.isEligible(book, config)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats new missing-field types as eligible (narrators, duration, abridged)', () => {
|
||||
const config = baseConfig();
|
||||
config.conditions.missingFields.enabled = true;
|
||||
|
||||
const missingNarrators = { ...baseBook(), hasNarrators: false };
|
||||
config.conditions.missingFields.fields = ['narrators'];
|
||||
expect(service.isEligible(missingNarrators, config)).toBe(true);
|
||||
|
||||
const missingDuration = { ...baseBook(), durationSeconds: null };
|
||||
config.conditions.missingFields.fields = ['duration'];
|
||||
expect(service.isEligible(missingDuration, config)).toBe(true);
|
||||
|
||||
const missingAbridged = { ...baseBook(), abridged: null };
|
||||
config.conditions.missingFields.fields = ['abridged'];
|
||||
expect(service.isEligible(missingAbridged, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats empty strings as missing for scalar text fields', () => {
|
||||
const book = { ...baseBook(), title: '' };
|
||||
const config = baseConfig();
|
||||
config.conditions.missingFields.enabled = true;
|
||||
config.conditions.missingFields.fields = ['title'];
|
||||
|
||||
expect(service.isEligible(book, config)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat numeric zero values as missing', () => {
|
||||
const book = { ...baseBook(), pageCount: 0, seriesIndex: 0 };
|
||||
const config = baseConfig();
|
||||
config.conditions.missingFields.enabled = true;
|
||||
config.conditions.missingFields.fields = ['pageCount', 'seriesIndex'];
|
||||
|
||||
expect(service.isEligible(book, config)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores missing-fields condition when the field list is empty', () => {
|
||||
const book = { ...baseBook(), description: null };
|
||||
const config = baseConfig();
|
||||
config.conditions.missingFields.enabled = true;
|
||||
config.conditions.missingFields.fields = [];
|
||||
|
||||
expect(service.isEligible(book, config)).toBe(false);
|
||||
});
|
||||
});
|
||||
+88
-34
@@ -64,7 +64,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
|
||||
const queued = await this.queueRepo.upsertSchedule([bookId], reason);
|
||||
if (queued > 0) {
|
||||
this.session.sessionTotal += queued;
|
||||
this.session.addToTotal(queued);
|
||||
await this.emitStatus();
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
|
||||
const queued = await this.queueRepo.upsertSchedule(bookIds, 'manual_trigger');
|
||||
if (queued > 0) {
|
||||
this.session.sessionTotal += queued;
|
||||
this.session.addToTotal(queued);
|
||||
await this.unpauseIfNeeded();
|
||||
await this.emitStatus();
|
||||
void this.pollOnce();
|
||||
@@ -93,7 +93,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
|
||||
const queued = await this.queueRepo.upsertSchedule(bookIds, 'manual_trigger');
|
||||
if (queued > 0) {
|
||||
this.session.sessionTotal += queued;
|
||||
this.session.addToTotal(queued);
|
||||
await this.unpauseIfNeeded();
|
||||
await this.emitStatus();
|
||||
void this.pollOnce();
|
||||
@@ -126,7 +126,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
async requeueFailed(): Promise<number> {
|
||||
const requeued = await this.queueRepo.requeueFailed();
|
||||
if (requeued > 0) {
|
||||
this.session.sessionTotal += requeued;
|
||||
this.session.addToTotal(requeued);
|
||||
await this.emitStatus();
|
||||
}
|
||||
return requeued;
|
||||
@@ -135,6 +135,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
private async pollOnce(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await this.queueRepo.recoverStuckProcessing();
|
||||
await this.checkAndResetSession();
|
||||
@@ -146,24 +147,33 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
await this.randomDelay();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`book-metadata-fetch poll failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
const errorClass = error instanceof Error ? error.name : 'Error';
|
||||
const message = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
|
||||
this.logger.warn(
|
||||
`[book.metadata_fetch.poll] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${message}" - metadata fetch poll failed`,
|
||||
);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processOne(bookId: number, title: string | null): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const claimed = await this.queueRepo.markProcessing(bookId);
|
||||
if (!claimed) return;
|
||||
this.session.currentItemName = title ?? null;
|
||||
this.session.setCurrentItemName(title ?? null);
|
||||
this.logger.debug(`[book.metadata_fetch] [start] bookId=${bookId} - metadata fetch started`);
|
||||
await this.emitStatus();
|
||||
|
||||
try {
|
||||
const found = await this.bookReadService.findById(bookId);
|
||||
if (!found) {
|
||||
await this.queueRepo.markDone(bookId);
|
||||
this.session.sessionDone++;
|
||||
this.session.currentItemName = null;
|
||||
this.session.incrementDone();
|
||||
this.session.setCurrentItemName(null);
|
||||
this.logger.debug(
|
||||
`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=book_missing - metadata fetch completed`,
|
||||
);
|
||||
await this.emitStatus();
|
||||
return;
|
||||
}
|
||||
@@ -205,21 +215,28 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
|
||||
this.scoreService
|
||||
.calculateAndSave(bookId)
|
||||
.catch((err: Error) => this.logger.warn(`book-metadata-fetch score recalc failed for book ${bookId}: ${err.message}`));
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(
|
||||
`[book.metadata_fetch.score_recalc] [fail] bookId=${bookId} errorClass=${err.name} error="${err.message.replace(/"/g, '\\"')}" - metadata score recalculation failed`,
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.debug(`[book.metadata_fetch] [end] bookId=${bookId} - metadata fetch completed`);
|
||||
this.logger.debug(`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} - metadata fetch completed`);
|
||||
await this.queueRepo.markDone(bookId);
|
||||
this.session.sessionDone++;
|
||||
this.session.currentItemName = null;
|
||||
this.session.incrementDone();
|
||||
this.session.setCurrentItemName(null);
|
||||
await this.emitStatus();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const httpStatus = extractHttpStatus(error);
|
||||
const errorClass = error instanceof Error ? error.name : 'Error';
|
||||
this.logger.warn(
|
||||
`[book.metadata_fetch] [fail] bookId=${bookId} status=${httpStatus ?? 'none'} error="${message.slice(0, 200)}" - metadata fetch failed`,
|
||||
`[book.metadata_fetch] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} status=${httpStatus ?? 'none'} errorClass=${errorClass} error="${message
|
||||
.slice(0, 200)
|
||||
.replace(/"/g, '\\"')}" - metadata fetch failed`,
|
||||
);
|
||||
await this.queueRepo.markFailed(bookId, message, httpStatus);
|
||||
this.session.currentItemName = null;
|
||||
this.session.setCurrentItemName(null);
|
||||
await this.emitStatus();
|
||||
}
|
||||
}
|
||||
@@ -232,18 +249,26 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
existingGenreRows: { name: string }[],
|
||||
existingNarratorRows: { name: string }[],
|
||||
): Promise<void> {
|
||||
const r = resolved as Record<string, unknown>;
|
||||
const scalarFields: Partial<typeof schema.bookMetadata.$inferInsert> = {};
|
||||
|
||||
if (r.title !== undefined) scalarFields.title = (r.title as string | null) ?? null;
|
||||
if (r.subtitle !== undefined) scalarFields.subtitle = (r.subtitle as string | null) ?? null;
|
||||
if (r.description !== undefined) scalarFields.description = (r.description as string | null) ?? null;
|
||||
if (r.publisher !== undefined) scalarFields.publisher = (r.publisher as string | null) ?? null;
|
||||
if (r.publishedYear !== undefined) scalarFields.publishedYear = (r.publishedYear as number | null) ?? null;
|
||||
if (r.language !== undefined) scalarFields.language = (r.language as string | null) ?? null;
|
||||
if (r.pageCount !== undefined) scalarFields.pageCount = (r.pageCount as number | null) ?? null;
|
||||
if (r.seriesName !== undefined) scalarFields.seriesName = (r.seriesName as string | null) ?? null;
|
||||
if (r.seriesIndex !== undefined) scalarFields.seriesIndex = (r.seriesIndex as number | null) ?? null;
|
||||
const title = this.asNullableString(resolved.title);
|
||||
if (title !== undefined) scalarFields.title = title;
|
||||
const subtitle = this.asNullableString(resolved.subtitle);
|
||||
if (subtitle !== undefined) scalarFields.subtitle = subtitle;
|
||||
const description = this.asNullableString(resolved.description);
|
||||
if (description !== undefined) scalarFields.description = description;
|
||||
const publisher = this.asNullableString(resolved.publisher);
|
||||
if (publisher !== undefined) scalarFields.publisher = publisher;
|
||||
const publishedYear = this.asNullableNumber(resolved.publishedYear);
|
||||
if (publishedYear !== undefined) scalarFields.publishedYear = publishedYear;
|
||||
const language = this.asNullableString(resolved.language);
|
||||
if (language !== undefined) scalarFields.language = language;
|
||||
const pageCount = this.asNullableNumber(resolved.pageCount);
|
||||
if (pageCount !== undefined) scalarFields.pageCount = pageCount;
|
||||
const seriesName = this.asNullableString(resolved.seriesName);
|
||||
if (seriesName !== undefined) scalarFields.seriesName = seriesName;
|
||||
const seriesIndex = this.asNullableNumber(resolved.seriesIndex);
|
||||
if (seriesIndex !== undefined) scalarFields.seriesIndex = seriesIndex;
|
||||
|
||||
if (providerIds[MetadataProviderKey.GOOGLE]) scalarFields.googleBooksId = providerIds[MetadataProviderKey.GOOGLE];
|
||||
if (providerIds[MetadataProviderKey.GOODREADS]) scalarFields.goodreadsId = providerIds[MetadataProviderKey.GOODREADS];
|
||||
@@ -253,18 +278,21 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
if (providerIds[MetadataProviderKey.ITUNES]) scalarFields.itunesId = providerIds[MetadataProviderKey.ITUNES];
|
||||
if (providerIds[MetadataProviderKey.AUDIBLE]) scalarFields.audibleId = providerIds[MetadataProviderKey.AUDIBLE];
|
||||
|
||||
if (r.duration !== undefined) scalarFields.durationSeconds = (r.duration as number | null) ?? null;
|
||||
// Only overwrite abridged when the provider gives a definitive boolean — never let null
|
||||
const duration = this.asNullableNumber(resolved.duration);
|
||||
if (duration !== undefined) scalarFields.durationSeconds = duration;
|
||||
// Only overwrite abridged when the provider gives a definitive boolean - never let null
|
||||
// overwrite an existing true value.
|
||||
if (r.abridged != null) scalarFields.abridged = r.abridged as boolean;
|
||||
if (resolved.chapters !== undefined) scalarFields.chapters = resolved.chapters as unknown;
|
||||
const abridged = this.asBoolean(resolved.abridged);
|
||||
if (abridged !== undefined) scalarFields.abridged = abridged;
|
||||
if (resolved.chapters !== undefined) scalarFields.chapters = resolved.chapters;
|
||||
|
||||
scalarFields.lastMetadataFetchAt = new Date();
|
||||
scalarFields.updatedAt = new Date();
|
||||
await this.bookReadService.updateMetadataFields(bookId, scalarFields);
|
||||
|
||||
if (r.authors !== undefined) {
|
||||
const names = r.authors as string[];
|
||||
const resolvedAuthors = this.asStringArray(resolved.authors);
|
||||
if (resolvedAuthors !== undefined) {
|
||||
const names = resolvedAuthors;
|
||||
if (names.length > 0 || existingAuthorRows.length > 0) {
|
||||
await this.metadataService.replaceAuthors(
|
||||
bookId,
|
||||
@@ -273,15 +301,17 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
}
|
||||
}
|
||||
|
||||
if (r.genres !== undefined) {
|
||||
const names = r.genres as string[];
|
||||
const resolvedGenres = this.asStringArray(resolved.genres);
|
||||
if (resolvedGenres !== undefined) {
|
||||
const names = resolvedGenres;
|
||||
if (names.length > 0 || existingGenreRows.length > 0) {
|
||||
await this.metadataService.replaceGenres(bookId, names);
|
||||
}
|
||||
}
|
||||
|
||||
if (r.narrators !== undefined) {
|
||||
const names = r.narrators as string[];
|
||||
const resolvedNarrators = this.asStringArray(resolved.narrators);
|
||||
if (resolvedNarrators !== undefined) {
|
||||
const names = resolvedNarrators;
|
||||
if (names.length > 0 || existingNarratorRows.length > 0) {
|
||||
await this.metadataService.replaceNarrators(
|
||||
bookId,
|
||||
@@ -368,6 +398,30 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
const summary = await this.queueRepo.getStatusSummary();
|
||||
this.gateway.emitStatus({ ...summary, paused: this.paused, ...this.session.getSnapshot() });
|
||||
}
|
||||
|
||||
private asNullableString(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
private asNullableNumber(value: unknown): number | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
private asBoolean(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
private asStringArray(value: unknown): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
if (!value.every((item) => typeof item === 'string')) return undefined;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function extractHttpStatus(error: unknown): number | undefined {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
vi.mock('drizzle-orm', () => {
|
||||
const sqlFn = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values }));
|
||||
(sqlFn as typeof sqlFn & { join: ReturnType<typeof vi.fn> }).join = vi.fn((chunks: unknown[], separator: unknown) => ({
|
||||
op: 'sql.join',
|
||||
chunks,
|
||||
separator,
|
||||
}));
|
||||
|
||||
return {
|
||||
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
|
||||
asc: vi.fn((value: unknown) => ({ op: 'asc', value })),
|
||||
count: vi.fn(() => ({ op: 'count' })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })),
|
||||
or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })),
|
||||
sql: sqlFn,
|
||||
};
|
||||
});
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import type { BookMetadataFetchConfig } from '@projectx/types';
|
||||
|
||||
import { bookMetadata, bookMetadataFetchQueue, bookNarrators, books } from '../../db/schema';
|
||||
import { BookMetadataFetchQueueRepository } from './book-metadata-fetch-queue.repository';
|
||||
|
||||
describe('BookMetadataFetchQueueRepository', () => {
|
||||
const makeDb = () => {
|
||||
const insertBuilder = {
|
||||
values: vi.fn(),
|
||||
onConflictDoUpdate: vi.fn(),
|
||||
returning: vi.fn(),
|
||||
};
|
||||
insertBuilder.values.mockReturnValue(insertBuilder);
|
||||
insertBuilder.onConflictDoUpdate.mockReturnValue(insertBuilder);
|
||||
insertBuilder.returning.mockResolvedValue([]);
|
||||
|
||||
const selectBuilder = {
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
innerJoin: vi.fn(),
|
||||
leftJoin: vi.fn(),
|
||||
orderBy: vi.fn(),
|
||||
limit: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
offset: vi.fn(),
|
||||
};
|
||||
selectBuilder.from.mockReturnValue(selectBuilder);
|
||||
selectBuilder.where.mockReturnValue(selectBuilder);
|
||||
selectBuilder.innerJoin.mockReturnValue(selectBuilder);
|
||||
selectBuilder.leftJoin.mockReturnValue(selectBuilder);
|
||||
selectBuilder.orderBy.mockReturnValue(selectBuilder);
|
||||
selectBuilder.offset.mockReturnValue(selectBuilder);
|
||||
selectBuilder.limit.mockResolvedValue([]);
|
||||
selectBuilder.groupBy.mockResolvedValue([]);
|
||||
|
||||
const updateBuilder = {
|
||||
set: vi.fn(),
|
||||
where: vi.fn(),
|
||||
returning: vi.fn(),
|
||||
};
|
||||
updateBuilder.set.mockReturnValue(updateBuilder);
|
||||
updateBuilder.where.mockReturnValue(updateBuilder);
|
||||
updateBuilder.returning.mockResolvedValue([]);
|
||||
|
||||
const deleteBuilder = {
|
||||
where: vi.fn(),
|
||||
returning: vi.fn(),
|
||||
};
|
||||
deleteBuilder.where.mockReturnValue(deleteBuilder);
|
||||
deleteBuilder.returning.mockResolvedValue([]);
|
||||
|
||||
return {
|
||||
db: {
|
||||
insert: vi.fn().mockReturnValue(insertBuilder),
|
||||
select: vi.fn().mockReturnValue(selectBuilder),
|
||||
update: vi.fn().mockReturnValue(updateBuilder),
|
||||
delete: vi.fn().mockReturnValue(deleteBuilder),
|
||||
},
|
||||
insertBuilder,
|
||||
selectBuilder,
|
||||
};
|
||||
};
|
||||
|
||||
const baseConfig = (): BookMetadataFetchConfig => ({
|
||||
enabled: true,
|
||||
triggerOnImport: false,
|
||||
conditions: {
|
||||
neverFetched: { enabled: false },
|
||||
scoreThreshold: { enabled: false, threshold: 60 },
|
||||
missingFields: { enabled: false, fields: [] },
|
||||
},
|
||||
});
|
||||
|
||||
it('upsertSchedule dedupes ids and only re-queues failed rows on conflict', async () => {
|
||||
const { db, insertBuilder } = makeDb();
|
||||
insertBuilder.returning.mockResolvedValueOnce([{ bookId: 1 }, { bookId: 2 }]);
|
||||
const repo = new BookMetadataFetchQueueRepository(db as never);
|
||||
|
||||
const queued = await repo.upsertSchedule([1, 1, 2, -1, 0], 'manual_trigger');
|
||||
|
||||
expect(queued).toBe(2);
|
||||
expect(insertBuilder.values).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ bookId: 1, status: 'queued', reason: 'manual_trigger', attemptCount: 0 }),
|
||||
expect.objectContaining({ bookId: 2, status: 'queued', reason: 'manual_trigger', attemptCount: 0 }),
|
||||
]);
|
||||
expect(eq).toHaveBeenCalledWith(bookMetadataFetchQueue.status, 'failed');
|
||||
expect(insertBuilder.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: bookMetadataFetchQueue.bookId,
|
||||
setWhere: expect.objectContaining({ op: 'eq', right: 'failed' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fetchEligibleBookIds supports narrators, duration, and abridged missing-field filters', async () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
selectBuilder.where.mockResolvedValueOnce([{ bookId: 7 }, { bookId: 8 }]);
|
||||
const repo = new BookMetadataFetchQueueRepository(db as never);
|
||||
const config = baseConfig();
|
||||
config.conditions.missingFields.enabled = true;
|
||||
config.conditions.missingFields.fields = ['narrators', 'duration', 'abridged'];
|
||||
|
||||
const ids = await repo.fetchEligibleBookIds(config, 5);
|
||||
|
||||
expect(ids).toEqual([7, 8]);
|
||||
expect(eq).toHaveBeenCalledWith(books.libraryId, 5);
|
||||
|
||||
const sqlCalls = vi.mocked(sql).mock.calls;
|
||||
const hasNarratorsClause = sqlCalls.some((call) => call.slice(1).includes(bookNarrators));
|
||||
const hasDurationClause = sqlCalls.some((call) => call.slice(1).includes(bookMetadata.durationSeconds));
|
||||
const hasAbridgedClause = sqlCalls.some((call) => call.slice(1).includes(bookMetadata.abridged));
|
||||
|
||||
expect(hasNarratorsClause).toBe(true);
|
||||
expect(hasDurationClause).toBe(true);
|
||||
expect(hasAbridgedClause).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty results without touching the database when no conditions are enabled', async () => {
|
||||
const { db } = makeDb();
|
||||
const repo = new BookMetadataFetchQueueRepository(db as never);
|
||||
|
||||
await expect(repo.fetchEligibleBookIds(baseConfig())).resolves.toEqual([]);
|
||||
await expect(repo.countEligibleBooks(baseConfig())).resolves.toBe(0);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('countEligibleBooks returns SQL count for valid predicates', async () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
selectBuilder.where.mockResolvedValueOnce([{ cnt: 3 }]);
|
||||
const repo = new BookMetadataFetchQueueRepository(db as never);
|
||||
const config = baseConfig();
|
||||
config.conditions.neverFetched.enabled = true;
|
||||
|
||||
await expect(repo.countEligibleBooks(config)).resolves.toBe(3);
|
||||
expect(and).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { BookMetadataFetchConfig, BookMetadataFetchFailedItem, BookMetadataFetchReason, BookMetadataFetchStatus } from '@projectx/types';
|
||||
import type {
|
||||
BookMetadataFetchConfig,
|
||||
BookMetadataFetchFailedItem,
|
||||
BookMetadataFetchReason,
|
||||
BookMetadataFetchStatus,
|
||||
MetadataField,
|
||||
} from '@projectx/types';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { and, asc, count, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { bookAuthors, bookGenres, bookMetadata, bookMetadataFetchQueue, books, libraries } from '../../db/schema';
|
||||
import { bookAuthors, bookGenres, bookMetadata, bookMetadataFetchQueue, bookNarrators, books, libraries } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
const PROCESSING_STALE_AFTER_MS = 10 * 60 * 1000;
|
||||
const QUEUE_STATUS = {
|
||||
QUEUED: 'queued',
|
||||
PROCESSING: 'processing',
|
||||
FAILED: 'failed',
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class BookMetadataFetchQueueRepository {
|
||||
@@ -25,7 +37,7 @@ export class BookMetadataFetchQueueRepository {
|
||||
.values(
|
||||
unique.map((bookId) => ({
|
||||
bookId,
|
||||
status: 'queued',
|
||||
status: QUEUE_STATUS.QUEUED,
|
||||
reason,
|
||||
attemptCount: 0,
|
||||
})),
|
||||
@@ -33,13 +45,14 @@ export class BookMetadataFetchQueueRepository {
|
||||
.onConflictDoUpdate({
|
||||
target: bookMetadataFetchQueue.bookId,
|
||||
set: {
|
||||
status: 'queued',
|
||||
status: QUEUE_STATUS.QUEUED,
|
||||
reason,
|
||||
attemptCount: 0,
|
||||
updatedAt: now,
|
||||
},
|
||||
// Only reset rows that are not currently being processed
|
||||
setWhere: sql`${bookMetadataFetchQueue.status} <> 'processing'`,
|
||||
// Only re-queue failed rows. Already-queued rows are intentionally ignored
|
||||
// so progress totals only reflect newly scheduled work.
|
||||
setWhere: eq(bookMetadataFetchQueue.status, QUEUE_STATUS.FAILED),
|
||||
})
|
||||
.returning({ bookId: bookMetadataFetchQueue.bookId });
|
||||
|
||||
@@ -56,7 +69,7 @@ export class BookMetadataFetchQueueRepository {
|
||||
.from(bookMetadataFetchQueue)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookMetadataFetchQueue.bookId))
|
||||
.leftJoin(books, eq(books.id, bookMetadataFetchQueue.bookId))
|
||||
.where(eq(bookMetadataFetchQueue.status, 'queued'))
|
||||
.where(eq(bookMetadataFetchQueue.status, QUEUE_STATUS.QUEUED))
|
||||
.orderBy(asc(bookMetadataFetchQueue.createdAt), asc(bookMetadataFetchQueue.bookId))
|
||||
.limit(limit);
|
||||
}
|
||||
@@ -66,8 +79,13 @@ export class BookMetadataFetchQueueRepository {
|
||||
try {
|
||||
const updated = await this.db
|
||||
.update(bookMetadataFetchQueue)
|
||||
.set({ status: 'processing', lastAttemptAt: now, attemptCount: sql`${bookMetadataFetchQueue.attemptCount} + 1`, updatedAt: now })
|
||||
.where(and(eq(bookMetadataFetchQueue.bookId, bookId), eq(bookMetadataFetchQueue.status, 'queued')))
|
||||
.set({
|
||||
status: QUEUE_STATUS.PROCESSING,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: sql`${bookMetadataFetchQueue.attemptCount} + 1`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(bookMetadataFetchQueue.bookId, bookId), eq(bookMetadataFetchQueue.status, QUEUE_STATUS.QUEUED)))
|
||||
.returning({ bookId: bookMetadataFetchQueue.bookId });
|
||||
return updated.length > 0;
|
||||
} catch (error) {
|
||||
@@ -84,14 +102,14 @@ export class BookMetadataFetchQueueRepository {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.update(bookMetadataFetchQueue)
|
||||
.set({ status: 'failed', lastError: error.slice(0, 2000), lastHttpStatus: httpStatus ?? null, updatedAt: now })
|
||||
.set({ status: QUEUE_STATUS.FAILED, lastError: error.slice(0, 2000), lastHttpStatus: httpStatus ?? null, updatedAt: now })
|
||||
.where(eq(bookMetadataFetchQueue.bookId, bookId));
|
||||
}
|
||||
|
||||
async cancelPending(): Promise<number> {
|
||||
const deleted = await this.db
|
||||
.delete(bookMetadataFetchQueue)
|
||||
.where(eq(bookMetadataFetchQueue.status, 'queued'))
|
||||
.where(eq(bookMetadataFetchQueue.status, QUEUE_STATUS.QUEUED))
|
||||
.returning({ bookId: bookMetadataFetchQueue.bookId });
|
||||
return deleted.length;
|
||||
}
|
||||
@@ -100,8 +118,8 @@ export class BookMetadataFetchQueueRepository {
|
||||
const now = new Date();
|
||||
const updated = await this.db
|
||||
.update(bookMetadataFetchQueue)
|
||||
.set({ status: 'queued', reason: 'manual_retry', lastError: null, lastHttpStatus: null, attemptCount: 0, updatedAt: now })
|
||||
.where(eq(bookMetadataFetchQueue.status, 'failed'))
|
||||
.set({ status: QUEUE_STATUS.QUEUED, reason: 'manual_retry', lastError: null, lastHttpStatus: null, attemptCount: 0, updatedAt: now })
|
||||
.where(eq(bookMetadataFetchQueue.status, QUEUE_STATUS.FAILED))
|
||||
.returning({ bookId: bookMetadataFetchQueue.bookId });
|
||||
return updated.length;
|
||||
}
|
||||
@@ -115,9 +133,9 @@ export class BookMetadataFetchQueueRepository {
|
||||
const summary = { queued: 0, processing: 0, failed: 0 };
|
||||
for (const row of rows) {
|
||||
const value = Number(row.cnt);
|
||||
if (row.status === 'queued') summary.queued = value;
|
||||
else if (row.status === 'processing') summary.processing = value;
|
||||
else if (row.status === 'failed') summary.failed = value;
|
||||
if (row.status === QUEUE_STATUS.QUEUED) summary.queued = value;
|
||||
else if (row.status === QUEUE_STATUS.PROCESSING) summary.processing = value;
|
||||
else if (row.status === QUEUE_STATUS.FAILED) summary.failed = value;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
@@ -125,8 +143,8 @@ export class BookMetadataFetchQueueRepository {
|
||||
async resetAllProcessingOnBoot(): Promise<number> {
|
||||
const updated = await this.db
|
||||
.update(bookMetadataFetchQueue)
|
||||
.set({ status: 'queued', updatedAt: new Date() })
|
||||
.where(eq(bookMetadataFetchQueue.status, 'processing'))
|
||||
.set({ status: QUEUE_STATUS.QUEUED, updatedAt: new Date() })
|
||||
.where(eq(bookMetadataFetchQueue.status, QUEUE_STATUS.PROCESSING))
|
||||
.returning({ bookId: bookMetadataFetchQueue.bookId });
|
||||
return updated.length;
|
||||
}
|
||||
@@ -136,10 +154,10 @@ export class BookMetadataFetchQueueRepository {
|
||||
const now = new Date();
|
||||
const updated = await this.db
|
||||
.update(bookMetadataFetchQueue)
|
||||
.set({ status: 'queued', updatedAt: now })
|
||||
.set({ status: QUEUE_STATUS.QUEUED, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(bookMetadataFetchQueue.status, 'processing'),
|
||||
eq(bookMetadataFetchQueue.status, QUEUE_STATUS.PROCESSING),
|
||||
or(isNull(bookMetadataFetchQueue.lastAttemptAt), sql`${bookMetadataFetchQueue.lastAttemptAt} <= ${staleBefore}`),
|
||||
),
|
||||
)
|
||||
@@ -148,73 +166,8 @@ export class BookMetadataFetchQueueRepository {
|
||||
}
|
||||
|
||||
async fetchEligibleBookIds(config: BookMetadataFetchConfig, libraryId?: number): Promise<number[]> {
|
||||
const { conditions } = config;
|
||||
const conditionClauses: ReturnType<typeof sql>[] = [];
|
||||
|
||||
if (conditions.neverFetched.enabled) {
|
||||
conditionClauses.push(sql`${bookMetadata.lastMetadataFetchAt} IS NULL`);
|
||||
}
|
||||
|
||||
if (conditions.scoreThreshold.enabled) {
|
||||
conditionClauses.push(sql`(${bookMetadata.metadataScore} IS NULL OR ${bookMetadata.metadataScore} < ${conditions.scoreThreshold.threshold})`);
|
||||
}
|
||||
|
||||
if (conditions.missingFields.enabled && conditions.missingFields.fields.length > 0) {
|
||||
const fieldClauses: ReturnType<typeof sql>[] = [];
|
||||
for (const field of conditions.missingFields.fields) {
|
||||
switch (field) {
|
||||
case 'authors':
|
||||
fieldClauses.push(sql`NOT EXISTS (SELECT 1 FROM ${bookAuthors} WHERE ${bookAuthors.bookId} = ${bookMetadata.bookId})`);
|
||||
break;
|
||||
case 'genres':
|
||||
fieldClauses.push(sql`NOT EXISTS (SELECT 1 FROM ${bookGenres} WHERE ${bookGenres.bookId} = ${bookMetadata.bookId})`);
|
||||
break;
|
||||
case 'title':
|
||||
fieldClauses.push(sql`(${bookMetadata.title} IS NULL OR ${bookMetadata.title} = '')`);
|
||||
break;
|
||||
case 'subtitle':
|
||||
fieldClauses.push(sql`(${bookMetadata.subtitle} IS NULL OR ${bookMetadata.subtitle} = '')`);
|
||||
break;
|
||||
case 'description':
|
||||
fieldClauses.push(sql`(${bookMetadata.description} IS NULL OR ${bookMetadata.description} = '')`);
|
||||
break;
|
||||
case 'publisher':
|
||||
fieldClauses.push(sql`(${bookMetadata.publisher} IS NULL OR ${bookMetadata.publisher} = '')`);
|
||||
break;
|
||||
case 'publishedYear':
|
||||
fieldClauses.push(sql`${bookMetadata.publishedYear} IS NULL`);
|
||||
break;
|
||||
case 'language':
|
||||
fieldClauses.push(sql`(${bookMetadata.language} IS NULL OR ${bookMetadata.language} = '')`);
|
||||
break;
|
||||
case 'pageCount':
|
||||
fieldClauses.push(sql`${bookMetadata.pageCount} IS NULL`);
|
||||
break;
|
||||
case 'seriesName':
|
||||
fieldClauses.push(sql`(${bookMetadata.seriesName} IS NULL OR ${bookMetadata.seriesName} = '')`);
|
||||
break;
|
||||
case 'seriesIndex':
|
||||
fieldClauses.push(sql`${bookMetadata.seriesIndex} IS NULL`);
|
||||
break;
|
||||
case 'cover':
|
||||
fieldClauses.push(sql`${bookMetadata.coverSource} IS NULL`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fieldClauses.length > 0) {
|
||||
conditionClauses.push(sql`(${sql.join(fieldClauses, sql` OR `)})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditionClauses.length === 0) return [];
|
||||
|
||||
const notAlreadyQueued = sql`NOT EXISTS (SELECT 1 FROM ${bookMetadataFetchQueue} WHERE ${bookMetadataFetchQueue.bookId} = ${bookMetadata.bookId})`;
|
||||
const eligibilityClause = sql`(${sql.join(conditionClauses, sql` OR `)})`;
|
||||
|
||||
const whereClause =
|
||||
libraryId !== undefined
|
||||
? and(eq(books.status, 'present'), eq(books.libraryId, libraryId), eligibilityClause, notAlreadyQueued)
|
||||
: and(eq(books.status, 'present'), eligibilityClause, notAlreadyQueued);
|
||||
const whereClause = this.buildEligibleBooksWhereClause(config, libraryId);
|
||||
if (!whereClause) return [];
|
||||
|
||||
const rows = await this.db
|
||||
.select({ bookId: bookMetadata.bookId })
|
||||
@@ -226,73 +179,8 @@ export class BookMetadataFetchQueueRepository {
|
||||
}
|
||||
|
||||
async countEligibleBooks(config: BookMetadataFetchConfig, libraryId?: number): Promise<number> {
|
||||
const { conditions } = config;
|
||||
const conditionClauses: ReturnType<typeof sql>[] = [];
|
||||
|
||||
if (conditions.neverFetched.enabled) {
|
||||
conditionClauses.push(sql`${bookMetadata.lastMetadataFetchAt} IS NULL`);
|
||||
}
|
||||
|
||||
if (conditions.scoreThreshold.enabled) {
|
||||
conditionClauses.push(sql`(${bookMetadata.metadataScore} IS NULL OR ${bookMetadata.metadataScore} < ${conditions.scoreThreshold.threshold})`);
|
||||
}
|
||||
|
||||
if (conditions.missingFields.enabled && conditions.missingFields.fields.length > 0) {
|
||||
const fieldClauses: ReturnType<typeof sql>[] = [];
|
||||
for (const field of conditions.missingFields.fields) {
|
||||
switch (field) {
|
||||
case 'authors':
|
||||
fieldClauses.push(sql`NOT EXISTS (SELECT 1 FROM ${bookAuthors} WHERE ${bookAuthors.bookId} = ${bookMetadata.bookId})`);
|
||||
break;
|
||||
case 'genres':
|
||||
fieldClauses.push(sql`NOT EXISTS (SELECT 1 FROM ${bookGenres} WHERE ${bookGenres.bookId} = ${bookMetadata.bookId})`);
|
||||
break;
|
||||
case 'title':
|
||||
fieldClauses.push(sql`(${bookMetadata.title} IS NULL OR ${bookMetadata.title} = '')`);
|
||||
break;
|
||||
case 'subtitle':
|
||||
fieldClauses.push(sql`(${bookMetadata.subtitle} IS NULL OR ${bookMetadata.subtitle} = '')`);
|
||||
break;
|
||||
case 'description':
|
||||
fieldClauses.push(sql`(${bookMetadata.description} IS NULL OR ${bookMetadata.description} = '')`);
|
||||
break;
|
||||
case 'publisher':
|
||||
fieldClauses.push(sql`(${bookMetadata.publisher} IS NULL OR ${bookMetadata.publisher} = '')`);
|
||||
break;
|
||||
case 'publishedYear':
|
||||
fieldClauses.push(sql`${bookMetadata.publishedYear} IS NULL`);
|
||||
break;
|
||||
case 'language':
|
||||
fieldClauses.push(sql`(${bookMetadata.language} IS NULL OR ${bookMetadata.language} = '')`);
|
||||
break;
|
||||
case 'pageCount':
|
||||
fieldClauses.push(sql`${bookMetadata.pageCount} IS NULL`);
|
||||
break;
|
||||
case 'seriesName':
|
||||
fieldClauses.push(sql`(${bookMetadata.seriesName} IS NULL OR ${bookMetadata.seriesName} = '')`);
|
||||
break;
|
||||
case 'seriesIndex':
|
||||
fieldClauses.push(sql`${bookMetadata.seriesIndex} IS NULL`);
|
||||
break;
|
||||
case 'cover':
|
||||
fieldClauses.push(sql`${bookMetadata.coverSource} IS NULL`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fieldClauses.length > 0) {
|
||||
conditionClauses.push(sql`(${sql.join(fieldClauses, sql` OR `)})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditionClauses.length === 0) return 0;
|
||||
|
||||
const notAlreadyQueued = sql`NOT EXISTS (SELECT 1 FROM ${bookMetadataFetchQueue} WHERE ${bookMetadataFetchQueue.bookId} = ${bookMetadata.bookId})`;
|
||||
const eligibilityClause = sql`(${sql.join(conditionClauses, sql` OR `)})`;
|
||||
|
||||
const whereClause =
|
||||
libraryId !== undefined
|
||||
? and(eq(books.status, 'present'), eq(books.libraryId, libraryId), eligibilityClause, notAlreadyQueued)
|
||||
: and(eq(books.status, 'present'), eligibilityClause, notAlreadyQueued);
|
||||
const whereClause = this.buildEligibleBooksWhereClause(config, libraryId);
|
||||
if (!whereClause) return 0;
|
||||
|
||||
const rows = await this.db.select({ cnt: count() }).from(bookMetadata).innerJoin(books, eq(books.id, bookMetadata.bookId)).where(whereClause);
|
||||
|
||||
@@ -335,6 +223,82 @@ export class BookMetadataFetchQueueRepository {
|
||||
total: Number(totalRows[0]?.cnt ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
private buildEligibleBooksWhereClause(config: BookMetadataFetchConfig, libraryId?: number): SQL | null {
|
||||
const eligibilityClause = this.buildEligibilityClause(config);
|
||||
if (!eligibilityClause) return null;
|
||||
|
||||
const notAlreadyQueued = sql`NOT EXISTS (SELECT 1 FROM ${bookMetadataFetchQueue} WHERE ${bookMetadataFetchQueue.bookId} = ${bookMetadata.bookId})`;
|
||||
|
||||
const whereClause =
|
||||
libraryId !== undefined
|
||||
? and(eq(books.status, 'present'), eq(books.libraryId, libraryId), eligibilityClause, notAlreadyQueued)
|
||||
: and(eq(books.status, 'present'), eligibilityClause, notAlreadyQueued);
|
||||
return whereClause ?? null;
|
||||
}
|
||||
|
||||
private buildEligibilityClause(config: BookMetadataFetchConfig): SQL | null {
|
||||
const { conditions } = config;
|
||||
const conditionClauses: SQL[] = [];
|
||||
|
||||
if (conditions.neverFetched.enabled) {
|
||||
conditionClauses.push(sql`${bookMetadata.lastMetadataFetchAt} IS NULL`);
|
||||
}
|
||||
|
||||
if (conditions.scoreThreshold.enabled) {
|
||||
conditionClauses.push(sql`(${bookMetadata.metadataScore} IS NULL OR ${bookMetadata.metadataScore} < ${conditions.scoreThreshold.threshold})`);
|
||||
}
|
||||
|
||||
if (conditions.missingFields.enabled && conditions.missingFields.fields.length > 0) {
|
||||
const missingFieldClauses = conditions.missingFields.fields
|
||||
.map((field) => this.buildMissingFieldClause(field))
|
||||
.filter((clause): clause is SQL => clause !== null);
|
||||
|
||||
if (missingFieldClauses.length > 0) {
|
||||
conditionClauses.push(sql`(${sql.join(missingFieldClauses, sql` OR `)})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditionClauses.length === 0) return null;
|
||||
return sql`(${sql.join(conditionClauses, sql` OR `)})`;
|
||||
}
|
||||
|
||||
private buildMissingFieldClause(field: MetadataField): SQL | null {
|
||||
switch (field) {
|
||||
case 'authors':
|
||||
return sql`NOT EXISTS (SELECT 1 FROM ${bookAuthors} WHERE ${bookAuthors.bookId} = ${bookMetadata.bookId})`;
|
||||
case 'genres':
|
||||
return sql`NOT EXISTS (SELECT 1 FROM ${bookGenres} WHERE ${bookGenres.bookId} = ${bookMetadata.bookId})`;
|
||||
case 'narrators':
|
||||
return sql`NOT EXISTS (SELECT 1 FROM ${bookNarrators} WHERE ${bookNarrators.bookId} = ${bookMetadata.bookId})`;
|
||||
case 'duration':
|
||||
return sql`${bookMetadata.durationSeconds} IS NULL`;
|
||||
case 'abridged':
|
||||
return sql`${bookMetadata.abridged} IS NULL`;
|
||||
case 'cover':
|
||||
return sql`${bookMetadata.coverSource} IS NULL`;
|
||||
case 'title':
|
||||
return sql`(${bookMetadata.title} IS NULL OR ${bookMetadata.title} = '')`;
|
||||
case 'subtitle':
|
||||
return sql`(${bookMetadata.subtitle} IS NULL OR ${bookMetadata.subtitle} = '')`;
|
||||
case 'description':
|
||||
return sql`(${bookMetadata.description} IS NULL OR ${bookMetadata.description} = '')`;
|
||||
case 'publisher':
|
||||
return sql`(${bookMetadata.publisher} IS NULL OR ${bookMetadata.publisher} = '')`;
|
||||
case 'publishedYear':
|
||||
return sql`${bookMetadata.publishedYear} IS NULL`;
|
||||
case 'language':
|
||||
return sql`(${bookMetadata.language} IS NULL OR ${bookMetadata.language} = '')`;
|
||||
case 'pageCount':
|
||||
return sql`${bookMetadata.pageCount} IS NULL`;
|
||||
case 'seriesName':
|
||||
return sql`(${bookMetadata.seriesName} IS NULL OR ${bookMetadata.seriesName} = '')`;
|
||||
case 'seriesIndex':
|
||||
return sql`${bookMetadata.seriesIndex} IS NULL`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.service';
|
||||
|
||||
describe('BookMetadataFetchSessionService', () => {
|
||||
it('tracks totals and current item safely', () => {
|
||||
const session = new BookMetadataFetchSessionService();
|
||||
|
||||
session.addToTotal(3);
|
||||
session.setCurrentItemName('My Book');
|
||||
session.incrementDone();
|
||||
|
||||
expect(session.getSnapshot()).toEqual({
|
||||
sessionTotal: 3,
|
||||
sessionDone: 1,
|
||||
currentItemName: 'My Book',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let done exceed total and ignores non-positive total increments', () => {
|
||||
const session = new BookMetadataFetchSessionService();
|
||||
|
||||
session.addToTotal(0);
|
||||
session.addToTotal(-10);
|
||||
session.incrementDone();
|
||||
session.addToTotal(1);
|
||||
session.incrementDone();
|
||||
session.incrementDone();
|
||||
|
||||
expect(session.getSnapshot()).toEqual({
|
||||
sessionTotal: 1,
|
||||
sessionDone: 1,
|
||||
currentItemName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('resets all state', () => {
|
||||
const session = new BookMetadataFetchSessionService();
|
||||
session.addToTotal(2);
|
||||
session.incrementDone();
|
||||
session.setCurrentItemName('Current');
|
||||
|
||||
session.reset();
|
||||
|
||||
expect(session.getSnapshot()).toEqual({
|
||||
sessionTotal: 0,
|
||||
sessionDone: 0,
|
||||
currentItemName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,24 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class BookMetadataFetchSessionService {
|
||||
sessionTotal = 0;
|
||||
sessionDone = 0;
|
||||
currentItemName: string | null = null;
|
||||
private sessionTotal = 0;
|
||||
private sessionDone = 0;
|
||||
private currentItemName: string | null = null;
|
||||
|
||||
addToTotal(count: number): void {
|
||||
if (count <= 0) return;
|
||||
this.sessionTotal += count;
|
||||
}
|
||||
|
||||
incrementDone(): void {
|
||||
if (this.sessionTotal <= 0) return;
|
||||
if (this.sessionDone >= this.sessionTotal) return;
|
||||
this.sessionDone += 1;
|
||||
}
|
||||
|
||||
setCurrentItemName(name: string | null): void {
|
||||
this.currentItemName = name;
|
||||
}
|
||||
|
||||
getSnapshot(): { sessionTotal: number; sessionDone: number; currentItemName: string | null } {
|
||||
return { sessionTotal: this.sessionTotal, sessionDone: this.sessionDone, currentItemName: this.currentItemName };
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
import { BookMetadataFetchController } from './book-metadata-fetch.controller';
|
||||
import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.service';
|
||||
import { BookMetadataFetchQueueRepository } from './book-metadata-fetch-queue.repository';
|
||||
import { BookMetadataFetchConfigService } from './book-metadata-fetch-config.service';
|
||||
import { BookMetadataFetchOrchestratorService } from './book-metadata-fetch-orchestrator.service';
|
||||
|
||||
describe('BookMetadataFetchController', () => {
|
||||
let configService: Mocked<BookMetadataFetchConfigService>;
|
||||
let orchestrator: Mocked<BookMetadataFetchOrchestratorService>;
|
||||
let queueRepo: Mocked<BookMetadataFetchQueueRepository>;
|
||||
let session: Mocked<BookMetadataFetchSessionService>;
|
||||
let controller: BookMetadataFetchController;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = {
|
||||
getGlobalConfig: vi.fn(),
|
||||
setGlobalConfig: vi.fn(),
|
||||
getLibraryConfigWithLastRun: vi.fn(),
|
||||
setLibraryOverride: vi.fn(),
|
||||
getEffectiveConfig: vi.fn(),
|
||||
isPaused: vi.fn(),
|
||||
} as unknown as Mocked<BookMetadataFetchConfigService>;
|
||||
|
||||
orchestrator = {
|
||||
triggerGlobal: vi.fn(),
|
||||
triggerForLibrary: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
cancelPending: vi.fn(),
|
||||
requeueFailed: vi.fn(),
|
||||
} as unknown as Mocked<BookMetadataFetchOrchestratorService>;
|
||||
|
||||
queueRepo = {
|
||||
getStatusSummary: vi.fn(),
|
||||
countEligibleBooks: vi.fn(),
|
||||
getFailedItems: vi.fn(),
|
||||
} as unknown as Mocked<BookMetadataFetchQueueRepository>;
|
||||
|
||||
session = {
|
||||
getSnapshot: vi.fn(),
|
||||
} as unknown as Mocked<BookMetadataFetchSessionService>;
|
||||
|
||||
controller = new BookMetadataFetchController(configService, orchestrator, queueRepo, session);
|
||||
});
|
||||
|
||||
it('clamps failed-item pagination inputs to safe bounds', async () => {
|
||||
queueRepo.getFailedItems.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
|
||||
const result = await controller.getFailedItems(0, 999);
|
||||
|
||||
expect(queueRepo.getFailedItems).toHaveBeenCalledWith(1, 100);
|
||||
expect(result).toEqual({ items: [], total: 0, page: 1, limit: 100 });
|
||||
});
|
||||
|
||||
it('uses minimum limit of 1 for failed items', async () => {
|
||||
queueRepo.getFailedItems.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
|
||||
await controller.getFailedItems(3, 0);
|
||||
|
||||
expect(queueRepo.getFailedItems).toHaveBeenCalledWith(3, 1);
|
||||
});
|
||||
|
||||
it('builds preview config from dto conditions and libraryId', async () => {
|
||||
queueRepo.countEligibleBooks.mockResolvedValueOnce(12);
|
||||
|
||||
const result = await controller.previewCount({
|
||||
conditions: {
|
||||
neverFetched: { enabled: true },
|
||||
scoreThreshold: { enabled: true, threshold: 60 },
|
||||
missingFields: { enabled: true, fields: ['description', 'narrators'] },
|
||||
},
|
||||
libraryId: 9,
|
||||
});
|
||||
|
||||
expect(queueRepo.countEligibleBooks).toHaveBeenCalledWith(
|
||||
{
|
||||
enabled: true,
|
||||
triggerOnImport: false,
|
||||
conditions: {
|
||||
neverFetched: { enabled: true },
|
||||
scoreThreshold: { enabled: true, threshold: 60 },
|
||||
missingFields: { enabled: true, fields: ['description', 'narrators'] },
|
||||
},
|
||||
},
|
||||
9,
|
||||
);
|
||||
expect(result).toEqual({ count: 12 });
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ export class BookMetadataFetchController {
|
||||
@Put('config')
|
||||
@RequirePermission(Permission.ManageMetadataConfig)
|
||||
async updateConfig(@Body() dto: UpdateBookMetadataFetchConfigDto) {
|
||||
await this.configService.setGlobalConfig(dto as unknown as BookMetadataFetchConfig);
|
||||
await this.configService.setGlobalConfig(dto);
|
||||
return this.configService.getGlobalConfig();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class BookMetadataFetchController {
|
||||
@Put('config/libraries/:id')
|
||||
@RequirePermission(Permission.ManageMetadataConfig)
|
||||
async updateLibraryConfig(@Param('id', ParseIntPipe) libraryId: number, @Body() dto: UpdateLibraryBookMetadataFetchConfigDto) {
|
||||
const override = Object.keys(dto).length === 0 ? null : (dto as unknown as BookMetadataFetchConfigOverride);
|
||||
const override: BookMetadataFetchConfigOverride = Object.keys(dto).length === 0 ? null : dto;
|
||||
await this.configService.setLibraryOverride(libraryId, override);
|
||||
return this.configService.getEffectiveConfig(libraryId);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export class BookMetadataFetchController {
|
||||
const config: BookMetadataFetchConfig = {
|
||||
enabled: true,
|
||||
triggerOnImport: false,
|
||||
conditions: dto.conditions as BookMetadataFetchConfig['conditions'],
|
||||
conditions: dto.conditions,
|
||||
};
|
||||
const count = await this.queueRepo.countEligibleBooks(config, dto.libraryId);
|
||||
return { count };
|
||||
@@ -111,8 +111,9 @@ export class BookMetadataFetchController {
|
||||
@Query('page', new ParseIntPipe({ optional: true })) page = 1,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit = 50,
|
||||
) {
|
||||
const safeLimit = Math.min(limit, 100);
|
||||
const { items, total } = await this.queueRepo.getFailedItems(page, safeLimit);
|
||||
return { items, total, page, limit: safeLimit };
|
||||
const safePage = Math.max(page, 1);
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 100);
|
||||
const { items, total } = await this.queueRepo.getFailedItems(safePage, safeLimit);
|
||||
return { items, total, page: safePage, limit: safeLimit };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Permission, type BookMetadataFetchStatusEvent } from '@projectx/types';
|
||||
@@ -28,14 +28,14 @@ export class BookMetadataFetchGateway implements OnGatewayConnection, OnGatewayD
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
try {
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) throw new Error('No token provided');
|
||||
if (!token) throw new UnauthorizedException('No token provided');
|
||||
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token);
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
if (!user) throw new Error('User not found or token revoked');
|
||||
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
||||
|
||||
this.assertCanViewStatus(user);
|
||||
(client.data as Record<string, unknown>).user = user;
|
||||
(client.data as { user?: RequestUser }).user = user;
|
||||
this.logger.debug(`WS connected: user=${user.id} socket=${client.id}`);
|
||||
|
||||
const [summary, paused] = await Promise.all([this.queueRepo.getStatusSummary(), this.configService.isPaused()]);
|
||||
@@ -57,6 +57,6 @@ export class BookMetadataFetchGateway implements OnGatewayConnection, OnGatewayD
|
||||
private assertCanViewStatus(user: RequestUser): void {
|
||||
if (user.isSuperuser) return;
|
||||
if (user.permissions.includes(Permission.ManageMetadataConfig)) return;
|
||||
throw new Error('Missing permission: manage_metadata_config');
|
||||
throw new ForbiddenException('Missing permission: manage_metadata_config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsIn, IsInt, IsObject, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||
import type { BookMetadataFetchConditions, BookMetadataFetchConfig, MetadataField } from '@projectx/types';
|
||||
|
||||
import { ALL_METADATA_FIELDS } from '@projectx/types';
|
||||
|
||||
@@ -19,7 +20,7 @@ class MissingFieldsConditionDto {
|
||||
|
||||
@IsArray()
|
||||
@IsIn(ALL_METADATA_FIELDS, { each: true })
|
||||
fields: string[];
|
||||
fields: MetadataField[];
|
||||
}
|
||||
|
||||
class NeverFetchedConditionDto {
|
||||
@@ -27,7 +28,7 @@ class NeverFetchedConditionDto {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
class ConditionsDto {
|
||||
class ConditionsDto implements BookMetadataFetchConditions {
|
||||
@ValidateNested()
|
||||
@Type(() => ScoreConditionDto)
|
||||
scoreThreshold: ScoreConditionDto;
|
||||
@@ -41,7 +42,7 @@ class ConditionsDto {
|
||||
neverFetched: NeverFetchedConditionDto;
|
||||
}
|
||||
|
||||
export class UpdateBookMetadataFetchConfigDto {
|
||||
export class UpdateBookMetadataFetchConfigDto implements BookMetadataFetchConfig {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@@ -57,14 +58,14 @@ export class PreviewCountDto {
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ConditionsDto)
|
||||
conditions: ConditionsDto;
|
||||
conditions: BookMetadataFetchConditions;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
libraryId?: number;
|
||||
}
|
||||
|
||||
export class UpdateLibraryBookMetadataFetchConfigDto {
|
||||
export class UpdateLibraryBookMetadataFetchConfigDto implements Partial<BookMetadataFetchConfig> {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
@@ -76,5 +77,5 @@ export class UpdateLibraryBookMetadataFetchConfigDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ConditionsDto)
|
||||
conditions?: ConditionsDto;
|
||||
conditions?: BookMetadataFetchConditions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookmarkController } from './bookmark.controller';
|
||||
import type { CreateBookmarkDto } from './dto/create-bookmark.dto';
|
||||
|
||||
describe('BookmarkController', () => {
|
||||
const user: RequestUser = {
|
||||
id: 8,
|
||||
username: 'bookmark-user',
|
||||
name: 'Bookmark User',
|
||||
email: null,
|
||||
active: true,
|
||||
isDefaultPassword: false,
|
||||
tokenVersion: 1,
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
isSuperuser: false,
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const service = {
|
||||
getBookmarks: vi.fn(),
|
||||
createBookmark: vi.fn(),
|
||||
deleteBookmark: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new BookmarkController(service as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('delegates getBookmarks with book id and current user', async () => {
|
||||
service.getBookmarks.mockResolvedValue([]);
|
||||
|
||||
await controller.getBookmarks(5, user);
|
||||
|
||||
expect(service.getBookmarks).toHaveBeenCalledWith(5, user);
|
||||
});
|
||||
|
||||
it('delegates createBookmark with payload and user', async () => {
|
||||
const dto: CreateBookmarkDto = { cfi: 'epubcfi(/6/2)', title: 'Chapter 1' };
|
||||
service.createBookmark.mockResolvedValue({ id: 1 });
|
||||
|
||||
await controller.createBookmark(5, dto, user);
|
||||
|
||||
expect(service.createBookmark).toHaveBeenCalledWith(5, user, dto);
|
||||
});
|
||||
|
||||
it('delegates deleteBookmark with book, bookmark, and user', async () => {
|
||||
service.deleteBookmark.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteBookmark(5, 15, user);
|
||||
|
||||
expect(service.deleteBookmark).toHaveBeenCalledWith(5, 15, user);
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,12 @@ export class BookmarkController {
|
||||
|
||||
@Get()
|
||||
getBookmarks(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser) {
|
||||
return this.bookmarkService.getBookmarks(bookId, user.id);
|
||||
return this.bookmarkService.getBookmarks(bookId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
createBookmark(@Param('bookId', ParseIntPipe) bookId: number, @Body() dto: CreateBookmarkDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookmarkService.createBookmark(user.id, bookId, dto);
|
||||
return this.bookmarkService.createBookmark(bookId, user, dto);
|
||||
}
|
||||
|
||||
@Delete(':bookmarkId')
|
||||
@@ -26,6 +26,6 @@ export class BookmarkController {
|
||||
@Param('bookmarkId', ParseIntPipe) bookmarkId: number,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
await this.bookmarkService.deleteBookmark(bookId, bookmarkId, user.id);
|
||||
await this.bookmarkService.deleteBookmark(bookId, bookmarkId, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
|
||||
|
||||
async function errorsFor(value: Record<string, unknown>) {
|
||||
const dto = plainToInstance(CreateBookmarkDto, value);
|
||||
return validate(dto);
|
||||
}
|
||||
|
||||
describe('Bookmark DTO validation', () => {
|
||||
it('requires at least one location field: cfi or positionSeconds', async () => {
|
||||
const errors = await errorsFor({ title: 'Chapter 1' });
|
||||
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
constraints: expect.objectContaining({
|
||||
bookmarkLocation: 'Either cfi or positionSeconds must be provided',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid CFI bookmarks and valid audio-position bookmarks', async () => {
|
||||
expect((await errorsFor({ title: 'Chapter 1', cfi: 'epubcfi(/6/2)' })).length).toBe(0);
|
||||
expect((await errorsFor({ title: '00:01:40', positionSeconds: 100 })).length).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects empty title and empty CFI', async () => {
|
||||
expect((await errorsFor({ title: '', cfi: 'epubcfi(/6/2)' })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor({ title: 'x', cfi: '' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('enforces CFI and title max lengths', async () => {
|
||||
expect((await errorsFor({ title: 'x', cfi: 'a'.repeat(2001) })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor({ title: 'a'.repeat(501), cfi: 'epubcfi(/6/2)' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('enforces non-negative numeric positionSeconds', async () => {
|
||||
expect((await errorsFor({ title: 'x', positionSeconds: -0.1 })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor({ title: 'x', positionSeconds: 0 })).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { BookmarkController } from './bookmark.controller';
|
||||
import { BookmarkRepository } from './bookmark.repository';
|
||||
import { BookmarkService } from './bookmark.service';
|
||||
|
||||
@Module({
|
||||
imports: [BookModule],
|
||||
controllers: [BookmarkController],
|
||||
providers: [BookmarkService, BookmarkRepository],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { BookmarkRepository } from './bookmark.repository';
|
||||
|
||||
function makeRow(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: 1,
|
||||
userId: 10,
|
||||
bookId: 5,
|
||||
cfi: 'epubcfi(/6/2)',
|
||||
title: 'Chapter 1',
|
||||
positionSeconds: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
const selectResult = { from: vi.fn() };
|
||||
const fromResult = { where: vi.fn() };
|
||||
const whereResult = { orderBy: vi.fn() };
|
||||
const orderByResult = { limit: vi.fn() };
|
||||
|
||||
selectResult.from.mockReturnValue(fromResult);
|
||||
fromResult.where.mockReturnValue(whereResult);
|
||||
whereResult.orderBy.mockResolvedValue([]);
|
||||
orderByResult.limit.mockResolvedValue([]);
|
||||
|
||||
const insertResult = { values: vi.fn() };
|
||||
const valuesResult = { returning: vi.fn() };
|
||||
insertResult.values.mockReturnValue(valuesResult);
|
||||
valuesResult.returning.mockResolvedValue([]);
|
||||
|
||||
const deleteResult = { where: vi.fn() };
|
||||
const deleteWhereResult = { returning: vi.fn() };
|
||||
deleteResult.where.mockReturnValue(deleteWhereResult);
|
||||
deleteWhereResult.returning.mockResolvedValue([]);
|
||||
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue(selectResult),
|
||||
insert: vi.fn().mockReturnValue(insertResult),
|
||||
delete: vi.fn().mockReturnValue(deleteResult),
|
||||
_where: whereResult,
|
||||
_orderBy: orderByResult,
|
||||
_insert: insertResult,
|
||||
_values: valuesResult,
|
||||
_deleteWhere: deleteWhereResult,
|
||||
};
|
||||
return db;
|
||||
}
|
||||
|
||||
function makeRepository() {
|
||||
const db = makeDb();
|
||||
const repo = new BookmarkRepository(db as never);
|
||||
return { repo, db };
|
||||
}
|
||||
|
||||
describe('BookmarkRepository', () => {
|
||||
describe('findByBookId', () => {
|
||||
it('queries by book and user and applies deterministic ordering', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const rows = [makeRow(), makeRow({ id: 2 })];
|
||||
db._where.orderBy.mockResolvedValue(rows);
|
||||
|
||||
const result = await repo.findByBookId(5, 10);
|
||||
|
||||
expect(result).toEqual(rows);
|
||||
expect(db._where.orderBy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExistingByLocation', () => {
|
||||
it('returns first bookmark for duplicate CFI location', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const row = makeRow();
|
||||
db._where.orderBy.mockReturnValue(db._orderBy);
|
||||
db._orderBy.limit.mockResolvedValue([row]);
|
||||
|
||||
const result = await repo.findExistingByLocation(10, 5, { cfi: 'epubcfi(/6/2)', positionSeconds: null });
|
||||
|
||||
expect(result).toEqual(row);
|
||||
expect(db._orderBy.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('returns first bookmark for duplicate audio position', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const row = makeRow({ id: 4, cfi: null, positionSeconds: 93.5 });
|
||||
db._where.orderBy.mockReturnValue(db._orderBy);
|
||||
db._orderBy.limit.mockResolvedValue([row]);
|
||||
|
||||
const result = await repo.findExistingByLocation(10, 5, { cfi: null, positionSeconds: 93.5 });
|
||||
|
||||
expect(result).toEqual(row);
|
||||
expect(db._orderBy.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('returns null when no location fields are provided', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
|
||||
const result = await repo.findExistingByLocation(10, 5, { cfi: null, positionSeconds: null });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when no duplicate exists', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
db._where.orderBy.mockReturnValue(db._orderBy);
|
||||
db._orderBy.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findExistingByLocation(10, 5, { cfi: 'epubcfi(/6/2)', positionSeconds: null });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('inserts and returns created row', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const row = makeRow({ id: 8, cfi: null, positionSeconds: 15, title: '00:00:15' });
|
||||
db._values.returning.mockResolvedValue([row]);
|
||||
|
||||
const result = await repo.create(10, 5, { cfi: null, title: '00:00:15', positionSeconds: 15 });
|
||||
|
||||
expect(db._insert.values).toHaveBeenCalledWith({ userId: 10, bookId: 5, cfi: null, title: '00:00:15', positionSeconds: 15 });
|
||||
expect(result).toEqual(row);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('returns true when delete query returns at least one row', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
db._deleteWhere.returning.mockResolvedValue([{ id: 1 }]);
|
||||
|
||||
const result = await repo.delete(5, 1, 10);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no rows are deleted', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
db._deleteWhere.returning.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.delete(5, 999, 10);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { bookmarks } from '../../db/schema';
|
||||
import { bookmarks, type BookmarkRow, type NewBookmark } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -16,10 +16,37 @@ export class BookmarkRepository {
|
||||
return this.db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(and(eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId)));
|
||||
.where(and(eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId)))
|
||||
.orderBy(asc(bookmarks.createdAt), asc(bookmarks.id));
|
||||
}
|
||||
|
||||
async create(userId: number, bookId: number, data: { cfi?: string; title: string; positionSeconds?: number }) {
|
||||
async findExistingByLocation(userId: number, bookId: number, data: Pick<NewBookmark, 'cfi' | 'positionSeconds'>): Promise<BookmarkRow | null> {
|
||||
if (data.cfi != null) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(and(eq(bookmarks.userId, userId), eq(bookmarks.bookId, bookId), eq(bookmarks.cfi, data.cfi)))
|
||||
.orderBy(asc(bookmarks.createdAt), asc(bookmarks.id))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
if (data.positionSeconds != null) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(
|
||||
and(eq(bookmarks.userId, userId), eq(bookmarks.bookId, bookId), eq(bookmarks.positionSeconds, data.positionSeconds), isNull(bookmarks.cfi)),
|
||||
)
|
||||
.orderBy(asc(bookmarks.createdAt), asc(bookmarks.id))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async create(userId: number, bookId: number, data: Pick<NewBookmark, 'cfi' | 'title' | 'positionSeconds'>) {
|
||||
const [row] = await this.db
|
||||
.insert(bookmarks)
|
||||
.values({ userId, bookId, cfi: data.cfi ?? null, title: data.title, positionSeconds: data.positionSeconds ?? null })
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookmarkService } from './bookmark.service';
|
||||
import { BookmarkResponseDto } from './dto/bookmark-response.dto';
|
||||
import type { CreateBookmarkDto } from './dto/create-bookmark.dto';
|
||||
|
||||
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'bookmarker',
|
||||
name: 'Book Marker',
|
||||
email: null,
|
||||
active: true,
|
||||
isSuperuser: false,
|
||||
isDefaultPassword: false,
|
||||
tokenVersion: 1,
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
permissions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBookmarkRow(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: 10,
|
||||
userId: 1,
|
||||
bookId: 5,
|
||||
cfi: 'epubcfi(/6/4!/4/2/1:0)',
|
||||
title: 'Chapter 1',
|
||||
positionSeconds: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
const bookmarkRepo = {
|
||||
findByBookId: vi.fn(),
|
||||
findExistingByLocation: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
const bookService = {
|
||||
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new BookmarkService(bookmarkRepo as never, bookService as never);
|
||||
return { service, bookmarkRepo, bookService };
|
||||
}
|
||||
|
||||
describe('BookmarkService', () => {
|
||||
describe('getBookmarks', () => {
|
||||
it('verifies access and returns mapped response DTOs', async () => {
|
||||
const { service, bookmarkRepo, bookService } = makeService();
|
||||
const user = makeUser();
|
||||
bookmarkRepo.findByBookId.mockResolvedValue([makeBookmarkRow()]);
|
||||
|
||||
const result = await service.getBookmarks(5, user);
|
||||
|
||||
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(5, user);
|
||||
expect(bookmarkRepo.findByBookId).toHaveBeenCalledWith(5, user.id);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(BookmarkResponseDto);
|
||||
expect(result[0]).not.toHaveProperty('userId');
|
||||
});
|
||||
|
||||
it('propagates access errors', async () => {
|
||||
const { service, bookService, bookmarkRepo } = makeService();
|
||||
bookService.verifyBookAccess.mockRejectedValue(new ForbiddenException());
|
||||
|
||||
await expect(service.getBookmarks(5, makeUser())).rejects.toThrow(ForbiddenException);
|
||||
expect(bookmarkRepo.findByBookId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBookmark', () => {
|
||||
it('returns an existing bookmark for duplicate CFI requests', async () => {
|
||||
const { service, bookmarkRepo, bookService } = makeService();
|
||||
const user = makeUser();
|
||||
const existing = makeBookmarkRow();
|
||||
bookmarkRepo.findExistingByLocation.mockResolvedValue(existing);
|
||||
|
||||
const dto: CreateBookmarkDto = { cfi: 'epubcfi(/6/4!/4/2/1:0)', title: 'Chapter 1' };
|
||||
const result = await service.createBookmark(5, user, dto);
|
||||
|
||||
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(5, user);
|
||||
expect(bookmarkRepo.findExistingByLocation).toHaveBeenCalledWith(1, 5, {
|
||||
cfi: 'epubcfi(/6/4!/4/2/1:0)',
|
||||
positionSeconds: null,
|
||||
});
|
||||
expect(bookmarkRepo.create).not.toHaveBeenCalled();
|
||||
expect(result.id).toBe(existing.id);
|
||||
});
|
||||
|
||||
it('returns an existing bookmark for duplicate audio position requests', async () => {
|
||||
const { service, bookmarkRepo } = makeService();
|
||||
const existing = makeBookmarkRow({ id: 11, cfi: null, positionSeconds: 93.5, title: '00:01:33' });
|
||||
bookmarkRepo.findExistingByLocation.mockResolvedValue(existing);
|
||||
|
||||
const result = await service.createBookmark(5, makeUser(), { title: '00:01:33', positionSeconds: 93.5 });
|
||||
|
||||
expect(bookmarkRepo.findExistingByLocation).toHaveBeenCalledWith(1, 5, { cfi: null, positionSeconds: 93.5 });
|
||||
expect(bookmarkRepo.create).not.toHaveBeenCalled();
|
||||
expect(result.id).toBe(11);
|
||||
expect(result.positionSeconds).toBe(93.5);
|
||||
});
|
||||
|
||||
it('creates and maps a bookmark when no duplicate exists', async () => {
|
||||
const { service, bookmarkRepo } = makeService();
|
||||
const createdRow = makeBookmarkRow({ id: 12, cfi: null, positionSeconds: 42, title: '00:00:42' });
|
||||
bookmarkRepo.findExistingByLocation.mockResolvedValue(null);
|
||||
bookmarkRepo.create.mockResolvedValue(createdRow);
|
||||
|
||||
const result = await service.createBookmark(5, makeUser(), { title: '00:00:42', positionSeconds: 42 });
|
||||
|
||||
expect(bookmarkRepo.create).toHaveBeenCalledWith(1, 5, { cfi: null, title: '00:00:42', positionSeconds: 42 });
|
||||
expect(result).toBeInstanceOf(BookmarkResponseDto);
|
||||
expect(result.id).toBe(12);
|
||||
expect(result.cfi).toBeNull();
|
||||
expect(result.positionSeconds).toBe(42);
|
||||
});
|
||||
|
||||
it('propagates access errors and does not query repository', async () => {
|
||||
const { service, bookService, bookmarkRepo } = makeService();
|
||||
bookService.verifyBookAccess.mockRejectedValue(new NotFoundException('Book 5 not found'));
|
||||
|
||||
await expect(service.createBookmark(5, makeUser(), { title: 'x', cfi: 'epubcfi(/6/2)' })).rejects.toThrow(NotFoundException);
|
||||
expect(bookmarkRepo.findExistingByLocation).not.toHaveBeenCalled();
|
||||
expect(bookmarkRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBookmark', () => {
|
||||
it('verifies access and deletes bookmark', async () => {
|
||||
const { service, bookmarkRepo, bookService } = makeService();
|
||||
const user = makeUser();
|
||||
bookmarkRepo.delete.mockResolvedValue(true);
|
||||
|
||||
await expect(service.deleteBookmark(5, 10, user)).resolves.toBeUndefined();
|
||||
|
||||
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(5, user);
|
||||
expect(bookmarkRepo.delete).toHaveBeenCalledWith(5, 10, 1);
|
||||
});
|
||||
|
||||
it('throws NotFoundException with stable message when bookmark is missing', async () => {
|
||||
const { service, bookmarkRepo } = makeService();
|
||||
bookmarkRepo.delete.mockResolvedValue(false);
|
||||
|
||||
await expect(service.deleteBookmark(5, 99, makeUser())).rejects.toThrow(NotFoundException);
|
||||
await expect(service.deleteBookmark(5, 99, makeUser())).rejects.toThrow('Bookmark 99 not found for book 5');
|
||||
});
|
||||
|
||||
it('propagates access errors and skips delete query', async () => {
|
||||
const { service, bookService, bookmarkRepo } = makeService();
|
||||
bookService.verifyBookAccess.mockRejectedValue(new ForbiddenException());
|
||||
|
||||
await expect(service.deleteBookmark(5, 10, makeUser())).rejects.toThrow(ForbiddenException);
|
||||
expect(bookmarkRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,53 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { NewBookmark } from '../../db/schema';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { BookmarkRepository } from './bookmark.repository';
|
||||
import { BookmarkResponseDto } from './dto/bookmark-response.dto';
|
||||
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BookmarkService {
|
||||
constructor(private readonly bookmarkRepo: BookmarkRepository) {}
|
||||
constructor(
|
||||
private readonly bookmarkRepo: BookmarkRepository,
|
||||
private readonly bookService: BookService,
|
||||
) {}
|
||||
|
||||
async getBookmarks(bookId: number, userId: number) {
|
||||
return this.bookmarkRepo.findByBookId(bookId, userId);
|
||||
async getBookmarks(bookId: number, user: RequestUser): Promise<BookmarkResponseDto[]> {
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
const rows = await this.bookmarkRepo.findByBookId(bookId, user.id);
|
||||
return rows.map((row) => BookmarkResponseDto.from(row));
|
||||
}
|
||||
|
||||
async createBookmark(userId: number, bookId: number, dto: CreateBookmarkDto) {
|
||||
return this.bookmarkRepo.create(userId, bookId, { cfi: dto.cfi, title: dto.title, positionSeconds: dto.positionSeconds });
|
||||
async createBookmark(bookId: number, user: RequestUser, dto: CreateBookmarkDto): Promise<BookmarkResponseDto> {
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
const createData = this.buildCreateData(dto);
|
||||
const existing = await this.bookmarkRepo.findExistingByLocation(user.id, bookId, {
|
||||
cfi: createData.cfi,
|
||||
positionSeconds: createData.positionSeconds,
|
||||
});
|
||||
if (existing) return BookmarkResponseDto.from(existing);
|
||||
|
||||
const row = await this.bookmarkRepo.create(user.id, bookId, createData);
|
||||
return BookmarkResponseDto.from(row);
|
||||
}
|
||||
|
||||
async deleteBookmark(bookId: number, bookmarkId: number, userId: number) {
|
||||
const deleted = await this.bookmarkRepo.delete(bookId, bookmarkId, userId);
|
||||
if (!deleted) throw new NotFoundException(`Bookmark ${bookmarkId} not found for book ${bookId}`);
|
||||
async deleteBookmark(bookId: number, bookmarkId: number, user: RequestUser): Promise<void> {
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
const deleted = await this.bookmarkRepo.delete(bookId, bookmarkId, user.id);
|
||||
if (!deleted) throw new NotFoundException(this.notFoundMessage(bookId, bookmarkId));
|
||||
}
|
||||
|
||||
private buildCreateData(dto: CreateBookmarkDto): Pick<NewBookmark, 'cfi' | 'title' | 'positionSeconds'> {
|
||||
return {
|
||||
cfi: dto.cfi ?? null,
|
||||
title: dto.title,
|
||||
positionSeconds: dto.positionSeconds ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private notFoundMessage(bookId: number, bookmarkId: number): string {
|
||||
return `Bookmark ${bookmarkId} not found for book ${bookId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BookmarkRow } from '../../../db/schema';
|
||||
|
||||
export class BookmarkResponseDto {
|
||||
id!: number;
|
||||
bookId!: number;
|
||||
cfi!: string | null;
|
||||
title!: string;
|
||||
positionSeconds!: number | null;
|
||||
createdAt!: Date;
|
||||
|
||||
static from(row: BookmarkRow): BookmarkResponseDto {
|
||||
const dto = new BookmarkResponseDto();
|
||||
dto.id = row.id;
|
||||
dto.bookId = row.bookId;
|
||||
dto.cfi = row.cfi ?? null;
|
||||
dto.title = row.title;
|
||||
dto.positionSeconds = row.positionSeconds ?? null;
|
||||
dto.createdAt = row.createdAt;
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,57 @@
|
||||
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
registerDecorator,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ name: 'bookmarkLocation', async: false })
|
||||
class BookmarkLocationConstraint implements ValidatorConstraintInterface {
|
||||
validate(_: unknown, args: ValidationArguments): boolean {
|
||||
const dto = args.object as CreateBookmarkDto;
|
||||
return typeof dto.cfi === 'string' || typeof dto.positionSeconds === 'number';
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'Either cfi or positionSeconds must be provided';
|
||||
}
|
||||
}
|
||||
|
||||
function HasBookmarkLocation(options?: ValidationOptions) {
|
||||
return function (constructor: new (...args: unknown[]) => unknown) {
|
||||
registerDecorator({
|
||||
name: 'bookmarkLocation',
|
||||
target: constructor,
|
||||
propertyName: '',
|
||||
options,
|
||||
constraints: [],
|
||||
validator: BookmarkLocationConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@HasBookmarkLocation()
|
||||
export class CreateBookmarkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
cfi?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
positionSeconds?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user