fix(annotations): harden access validation and metadata fetch scheduling

This commit is contained in:
neon
2026-04-01 01:51:40 -06:00
parent e110a17d8d
commit 7a5ca257ba
22 changed files with 1308 additions and 233 deletions
@@ -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;
}
}
}
@@ -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);
});
});
@@ -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;
}