feat(reader): merge reader settings field by field over PATCH

PUT reader/defaults/:formatGroup demands the full settings object, so a client has to send every
field of a group to change one of them. That forces a client to own fields it does not model and to
echo back values it never shows, and it turns two clients editing different fields into a
last-writer-wins race over the whole row.

Add PATCH for both defaults and per-book preferences. The defaults patch merges over the stored
row with the built-in defaults folded in underneath, so the row stays a complete object for the web
client's sanitiser while a partial caller leaves no holes. The per-book patch takes set and unset
and resolves both in one statement, so a concurrent patch cannot lose a field to a read-modify-write,
and deletes the row once its last key goes, which is the same state as never having been customized.
Nulls are never stripped: a null fontFamily means "use the book's own font", not an absent key.

Keep only the keys the caller actually sent. Zod applies .default() while parsing a partial, so a
cbx patch that never mentioned spreadGap came back carrying one; the existing per-book PUT had the
same latent bug and now shares the fix, which stops a touched comic from being pinned to the
default gap.

iOS is the only caller of the new routes. It keeps the layout fields on the device and syncs the
look fields, which is exactly the split a partial write makes expressible. The web client does not
call them and still PUTs full snapshots.
This commit is contained in:
neon
2026-09-21 15:31:53 +00:00
parent 9afa3a2f70
commit a06b111046
9 changed files with 491 additions and 4 deletions
+28
View File
@@ -196,6 +196,34 @@ export const AUDIO_READER_DEFAULTS: AudioReaderSettings = {
skipForwardSeconds: 30,
};
/**
* Body of `PATCH /reader/defaults/:formatGroup`.
*
* Only the keys present in `set` are written. Every other key of the group keeps whatever the
* stored row already holds, so a client that owns a subset of the fields can save its own without
* having to send, and therefore without having to know, the rest. The iOS app is the only caller:
* it keeps the layout fields on the device and sends only the look fields.
*/
export interface ReaderDefaultsPatchBody<
G extends ReaderFormatGroup = ReaderFormatGroup,
> {
set: Partial<ReaderSettingsMap[G]>;
}
/**
* Body of `PATCH /reader/preferences/:bookFileId`.
*
* `set` pins fields on the book; `unset` removes them so the field falls back to the account
* default. At least one of the two must be non-empty, and a key may not appear in both. A row left
* with no keys is deleted, which is the same state as never having been customized.
*/
export interface ReaderPreferencePatchBody<
G extends ReaderFormatGroup = ReaderFormatGroup,
> {
set?: Partial<ReaderSettingsMap[G]>;
unset?: string[];
}
export const READER_GROUP_DEFAULTS: ReaderSettingsMap = {
epub: EPUB_READER_DEFAULTS,
pdf: PDF_READER_DEFAULTS,
@@ -0,0 +1,7 @@
import { IsObject, IsOptional } from 'class-validator';
export class PatchDefaultDto {
@IsObject()
@IsOptional()
set?: Record<string, unknown>;
}
@@ -0,0 +1,12 @@
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
export class PatchPreferenceDto {
@IsObject()
@IsOptional()
set?: Record<string, unknown>;
@IsArray()
@IsString({ each: true })
@IsOptional()
unset?: string[];
}
@@ -37,6 +37,8 @@ describe('ReaderPreferencesController', () => {
getAllDefaults: vi.fn(),
upsertDefault: vi.fn(),
deleteDefault: vi.fn(),
patchPreference: vi.fn(),
patchDefault: vi.fn(),
} as unknown as Mocked<ReaderPreferencesService>;
controller = new ReaderPreferencesController(service);
@@ -105,4 +107,26 @@ describe('ReaderPreferencesController', () => {
expect(service.deleteDefault).toHaveBeenCalledWith(55, 'pdf');
});
it('delegates a per-book patch to service, passing both lists through', async () => {
const user = makeUser({ id: 15 });
const dto = { set: { isDark: true }, unset: ['themeName'] };
await controller.patchPreference(5, dto, user);
expect(service.patchPreference).toHaveBeenCalledWith(user, 5, dto.set, dto.unset);
});
it('passes an absent set or unset straight through, so the service owns the empty-body rule', async () => {
const user = makeUser({ id: 15 });
await controller.patchPreference(5, {}, user);
expect(service.patchPreference).toHaveBeenCalledWith(user, 5, undefined, undefined);
});
it('delegates a defaults patch to service', async () => {
await controller.patchDefault('epub', { set: { isDark: true } }, makeUser({ id: 55 }));
expect(service.patchDefault).toHaveBeenCalledWith(55, 'epub', { isDark: true });
});
});
@@ -1,7 +1,9 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Patch, Put } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { PatchDefaultDto } from './dto/patch-default.dto';
import { PatchPreferenceDto } from './dto/patch-preference.dto';
import { UpsertPreferenceDto } from './dto/upsert-preference.dto';
import { ReaderPreferencesService } from './reader-preferences.service';
@@ -21,6 +23,12 @@ export class ReaderPreferencesController {
await this.readerPreferencesService.upsertPreference(user, bookFileId, dto.settings);
}
@Patch('preferences/:bookFileId')
@HttpCode(204)
async patchPreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @Body() dto: PatchPreferenceDto, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.patchPreference(user, bookFileId, dto.set, dto.unset);
}
@Delete('preferences/:bookFileId')
@HttpCode(204)
async deletePreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @CurrentUser() user: RequestUser) {
@@ -39,6 +47,12 @@ export class ReaderPreferencesController {
await this.readerPreferencesService.upsertDefault(user.id, formatGroup, dto.settings);
}
@Patch('defaults/:formatGroup')
@HttpCode(204)
async patchDefault(@Param('formatGroup') formatGroup: string, @Body() dto: PatchDefaultDto, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.patchDefault(user.id, formatGroup, dto.set);
}
@Delete('defaults/:formatGroup')
@HttpCode(204)
async deleteDefault(@Param('formatGroup') formatGroup: string, @CurrentUser() user: RequestUser) {
@@ -0,0 +1,124 @@
import { PgDialect } from 'drizzle-orm/pg-core';
import { ReaderPreferencesRepository } from './reader-preferences.repository';
const dialect = new PgDialect();
/**
* A patch has to merge inside one statement. These tests render the statement the repository
* builds, because "does the stored row keep the keys nobody sent" is a property of the SQL, not of
* anything a mocked call argument can show.
*/
function makeDb() {
const captured: { values?: unknown; conflictSet?: unknown; deleteWhere?: unknown } = {};
const insertChain = {
values: vi.fn((value: unknown) => {
captured.values = value;
return insertChain;
}),
onConflictDoUpdate: vi.fn((config: { set: unknown }) => {
captured.conflictSet = config.set;
return Promise.resolve();
}),
};
const deleteChain = {
where: vi.fn((where: unknown) => {
captured.deleteWhere = where;
return Promise.resolve();
}),
};
const db = {
insert: vi.fn(() => insertChain),
delete: vi.fn(() => deleteChain),
};
return { db, captured, insertChain, deleteChain };
}
function render(fragment: unknown) {
return dialect.sqlToQuery(fragment as never);
}
describe('ReaderPreferencesRepository', () => {
describe('patchDefault', () => {
it('merges the patch over the stored row instead of replacing it', async () => {
const { db, captured } = makeDb();
const repo = new ReaderPreferencesRepository(db as never);
await repo.patchDefault(4, 'epub', { fontSize: 16, isDark: false }, { isDark: true });
const onConflict = render((captured.conflictSet as { settings: unknown }).settings);
expect(onConflict.sql).toBe('($1::jsonb || "reader_default_preferences"."settings") || $2::jsonb');
expect(onConflict.params).toEqual([JSON.stringify({ fontSize: 16, isDark: false }), JSON.stringify({ isDark: true })]);
});
it('seeds a brand new row from the built-in defaults plus the patch, so it stays a full object', async () => {
const { db, captured } = makeDb();
const repo = new ReaderPreferencesRepository(db as never);
await repo.patchDefault(4, 'epub', { fontSize: 16, isDark: false }, { isDark: true });
const insert = render((captured.values as { settings: unknown }).settings);
expect(insert.sql).toBe('$1::jsonb || $2::jsonb');
expect(insert.params).toEqual([JSON.stringify({ fontSize: 16, isDark: false }), JSON.stringify({ isDark: true })]);
});
});
describe('patchPreference', () => {
it('removes the unset keys and merges the set keys in one statement', async () => {
const { db, captured } = makeDb();
const repo = new ReaderPreferencesRepository(db as never);
await repo.patchPreference(4, 88, { isDark: true }, ['themeName', 'lineHeight']);
const onConflict = render((captured.conflictSet as { settings: unknown }).settings);
expect(onConflict.sql).toBe('("reader_preferences"."settings" - ARRAY[$1, $2]::text[]) || $3::jsonb');
expect(onConflict.params).toEqual(['themeName', 'lineHeight', JSON.stringify({ isDark: true })]);
});
it('keeps an empty unset list valid rather than dropping the operator', async () => {
const { db, captured } = makeDb();
const repo = new ReaderPreferencesRepository(db as never);
await repo.patchPreference(4, 88, { isDark: true }, []);
const onConflict = render((captured.conflictSet as { settings: unknown }).settings);
expect(onConflict.sql).toBe('("reader_preferences"."settings" - ARRAY[]::text[]) || $1::jsonb');
});
it('never strips nulls, because a null fontFamily is a value', async () => {
const { db, captured } = makeDb();
const repo = new ReaderPreferencesRepository(db as never);
await repo.patchPreference(4, 88, { fontFamily: null }, []);
const onConflict = render((captured.conflictSet as { settings: unknown }).settings);
expect(onConflict.sql).not.toContain('jsonb_strip_nulls');
expect(onConflict.params).toContain(JSON.stringify({ fontFamily: null }));
});
it('deletes the row once its last key is unset', async () => {
const { db, captured, deleteChain } = makeDb();
const repo = new ReaderPreferencesRepository(db as never);
await repo.patchPreference(4, 88, {}, ['isDark']);
expect(db.delete).toHaveBeenCalledTimes(1);
expect(deleteChain.where).toHaveBeenCalledTimes(1);
expect(render(captured.deleteWhere).sql).toContain(`"reader_preferences"."settings" = '{}'::jsonb`);
});
it('reads nothing before writing, so a concurrent patch cannot lose a field', async () => {
const { db } = makeDb();
const repo = new ReaderPreferencesRepository(db as never);
await repo.patchPreference(4, 88, { isDark: true }, ['themeName']);
expect(db).not.toHaveProperty('select');
expect(db.insert).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { SQL, and, eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -7,6 +7,17 @@ import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
/** A parameterised `text[]` literal, so `jsonb - text[]` never interpolates a key. */
function textArray(values: string[]): SQL {
if (values.length === 0) {
return sql`ARRAY[]::text[]`;
}
return sql`ARRAY[${sql.join(
values.map((value) => sql`${value}`),
sql`, `,
)}]::text[]`;
}
@Injectable()
export class ReaderPreferencesRepository {
constructor(@Inject(DB) private readonly db: Db) {}
@@ -28,6 +39,41 @@ export class ReaderPreferencesRepository {
});
}
/**
* Merges `set` into the stored per-book row and drops every key in `unset`, in one statement so
* a concurrent patch cannot lose a field to a read-modify-write. A row left with no keys is
* deleted, because an empty row and an absent row mean the same thing to every reader.
*
* Never `jsonb_strip_nulls`: a null `fontFamily` is the value "use the book's own font", not an
* absent key.
*/
async patchPreference(userId: number, bookFileId: number, set: Record<string, unknown>, unset: string[]) {
const now = new Date();
const patch = JSON.stringify(set);
const removed = textArray(unset);
await this.db
.insert(schema.readerPreferences)
.values({ userId, bookFileId, settings: sql`${patch}::jsonb`, updatedAt: now })
.onConflictDoUpdate({
target: [schema.readerPreferences.userId, schema.readerPreferences.bookFileId],
set: {
settings: sql`(${schema.readerPreferences.settings} - ${removed}) || ${patch}::jsonb`,
updatedAt: now,
},
});
await this.db
.delete(schema.readerPreferences)
.where(
and(
eq(schema.readerPreferences.userId, userId),
eq(schema.readerPreferences.bookFileId, bookFileId),
sql`${schema.readerPreferences.settings} = '{}'::jsonb`,
),
);
}
async deletePreference(userId: number, bookFileId: number) {
await this.db
.delete(schema.readerPreferences)
@@ -51,6 +97,28 @@ export class ReaderPreferencesRepository {
});
}
/**
* Merges `set` into the stored defaults row in one statement. The built-in defaults are folded
* in underneath so the row stays a full object: the web client reads defaults as a complete
* settings struct, and a client that owns only some of the fields must not leave holes for it.
*/
async patchDefault(userId: number, formatGroup: string, builtinDefaults: Record<string, unknown>, set: Record<string, unknown>) {
const now = new Date();
const builtin = JSON.stringify(builtinDefaults);
const patch = JSON.stringify(set);
await this.db
.insert(schema.readerDefaultPreferences)
.values({ userId, formatGroup, settings: sql`${builtin}::jsonb || ${patch}::jsonb`, updatedAt: now })
.onConflictDoUpdate({
target: [schema.readerDefaultPreferences.userId, schema.readerDefaultPreferences.formatGroup],
set: {
settings: sql`(${builtin}::jsonb || ${schema.readerDefaultPreferences.settings}) || ${patch}::jsonb`,
updatedAt: now,
},
});
}
async deleteDefault(userId: number, formatGroup: string) {
await this.db
.delete(schema.readerDefaultPreferences)
@@ -5,7 +5,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { ReaderPreferencesRepository } from './reader-preferences.repository';
import { ReaderPreferencesService } from './reader-preferences.service';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import { CBX_READER_DEFAULTS, EMPTY_CONTENT_FILTER_RULES, EPUB_READER_DEFAULTS } from '@bookorbit/types';
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
return {
@@ -54,6 +54,8 @@ const mockRepo = {
findAllDefaults: vi.fn<(...args: [number]) => Promise<Array<{ formatGroup: string; settings: Record<string, unknown> }>>>(),
upsertDefault: vi.fn<(...args: [number, string, Record<string, unknown>]) => Promise<void>>(),
deleteDefault: vi.fn<(...args: [number, string]) => Promise<void>>(),
patchPreference: vi.fn<(...args: [number, number, Record<string, unknown>, string[]]) => Promise<void>>(),
patchDefault: vi.fn<(...args: [number, string, Record<string, unknown>, Record<string, unknown>]) => Promise<void>>(),
};
const mockBookService = {
@@ -72,6 +74,8 @@ describe('ReaderPreferencesService', () => {
mockRepo.findAllDefaults.mockResolvedValue([]);
mockRepo.upsertDefault.mockResolvedValue(undefined);
mockRepo.deleteDefault.mockResolvedValue(undefined);
mockRepo.patchPreference.mockResolvedValue(undefined);
mockRepo.patchDefault.mockResolvedValue(undefined);
service = new ReaderPreferencesService(mockRepo as unknown as ReaderPreferencesRepository, mockBookService as unknown as BookService);
});
@@ -514,4 +518,143 @@ describe('ReaderPreferencesService', () => {
await expect(service.upsertDefault(1, 'epub', withoutSpread)).rejects.toThrow(BadRequestException);
});
});
describe('patch routes', () => {
describe('patchDefault', () => {
it('writes only the keys the caller sent, with the built-in defaults underneath', async () => {
await service.patchDefault(9, 'epub', { themeName: 'sepia', isDark: true });
expect(mockRepo.patchDefault).toHaveBeenCalledWith(9, 'epub', EPUB_READER_DEFAULTS, { themeName: 'sepia', isDark: true });
});
it('leaves every untouched key out of the patch, so the stored row keeps its own value', async () => {
await service.patchDefault(9, 'epub', { lineHeight: 1.8 });
const [, , , set] = mockRepo.patchDefault.mock.calls[0]!;
expect(Object.keys(set)).toEqual(['lineHeight']);
expect(set).not.toHaveProperty('fontSize');
expect(set).not.toHaveProperty('maxInlineSize');
});
it('does not inject spreadGap into a cbx patch that never mentioned it', async () => {
await service.patchDefault(9, 'cbx', { bgColor: 'white' });
expect(mockRepo.patchDefault).toHaveBeenCalledWith(9, 'cbx', CBX_READER_DEFAULTS, { bgColor: 'white' });
});
it('rejects an unknown key', async () => {
await expect(service.patchDefault(9, 'epub', { nope: true })).rejects.toThrow(BadRequestException);
expect(mockRepo.patchDefault).not.toHaveBeenCalled();
});
it('rejects an out-of-range value', async () => {
await expect(service.patchDefault(9, 'epub', { fontSize: 99 })).rejects.toThrow(BadRequestException);
expect(mockRepo.patchDefault).not.toHaveBeenCalled();
});
it('rejects an unknown format group', async () => {
await expect(service.patchDefault(9, 'comics', { bgColor: 'white' })).rejects.toThrow(BadRequestException);
expect(mockRepo.patchDefault).not.toHaveBeenCalled();
});
it.each([[undefined], [{}]])('rejects an empty body (%s)', async (set) => {
await expect(service.patchDefault(9, 'epub', set as Record<string, unknown> | undefined)).rejects.toThrow(BadRequestException);
expect(mockRepo.patchDefault).not.toHaveBeenCalled();
});
});
describe('patchPreference', () => {
beforeEach(() => {
mockBookService.verifyFileAccess.mockResolvedValue({ format: 'epub' });
});
it('resolves the format group from the file and writes the sent keys', async () => {
const user = makeUser({ id: 31 });
await service.patchPreference(user, 77, { themeName: 'sepia' }, undefined);
expect(mockBookService.verifyFileAccess).toHaveBeenCalledWith(77, user);
expect(mockRepo.patchPreference).toHaveBeenCalledWith(31, 77, { themeName: 'sepia' }, []);
});
it('keeps a pin whose value equals the built-in default', async () => {
await service.patchPreference(makeUser(), 77, { lineHeight: 1.5 }, undefined);
expect(mockRepo.patchPreference).toHaveBeenCalledWith(7, 77, { lineHeight: 1.5 }, []);
});
it('keeps an explicit null apart from an absent key', async () => {
await service.patchPreference(makeUser(), 77, { fontFamily: null }, undefined);
expect(mockRepo.patchPreference).toHaveBeenCalledWith(7, 77, { fontFamily: null }, []);
});
it('accepts an unset-only body', async () => {
await service.patchPreference(makeUser(), 77, undefined, ['themeName', 'isDark']);
expect(mockRepo.patchPreference).toHaveBeenCalledWith(7, 77, {}, ['themeName', 'isDark']);
});
it('collapses duplicate unset keys', async () => {
await service.patchPreference(makeUser(), 77, undefined, ['isDark', 'isDark']);
expect(mockRepo.patchPreference).toHaveBeenCalledWith(7, 77, {}, ['isDark']);
});
it('rejects an unset key that is not part of the group schema', async () => {
await expect(service.patchPreference(makeUser(), 77, undefined, ['bgColor'])).rejects.toThrow(BadRequestException);
expect(mockRepo.patchPreference).not.toHaveBeenCalled();
});
it('rejects a key that appears in both set and unset', async () => {
await expect(service.patchPreference(makeUser(), 77, { isDark: true }, ['isDark'])).rejects.toThrow(BadRequestException);
expect(mockRepo.patchPreference).not.toHaveBeenCalled();
});
it.each([
[undefined, undefined],
[{}, []],
[{}, undefined],
])('rejects an empty body (%s, %s)', async (set, unset) => {
await expect(
service.patchPreference(makeUser(), 77, set as Record<string, unknown> | undefined, unset as string[] | undefined),
).rejects.toThrow(BadRequestException);
expect(mockRepo.patchPreference).not.toHaveBeenCalled();
});
it('rejects an unknown key', async () => {
await expect(service.patchPreference(makeUser(), 77, { nope: 1 }, undefined)).rejects.toThrow(BadRequestException);
expect(mockRepo.patchPreference).not.toHaveBeenCalled();
});
it('rejects an out-of-range value', async () => {
await expect(service.patchPreference(makeUser(), 77, { fontSize: 2 }, undefined)).rejects.toThrow(BadRequestException);
expect(mockRepo.patchPreference).not.toHaveBeenCalled();
});
it('does not inject spreadGap into a cbx per-book patch', async () => {
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
await service.patchPreference(makeUser(), 77, { direction: 'rtl' }, undefined);
expect(mockRepo.patchPreference).toHaveBeenCalledWith(7, 77, { direction: 'rtl' }, []);
});
it('writes nothing when file access fails', async () => {
mockBookService.verifyFileAccess.mockRejectedValueOnce(new ForbiddenException());
await expect(service.patchPreference(makeUser(), 77, { isDark: true }, undefined)).rejects.toThrow(ForbiddenException);
expect(mockRepo.patchPreference).not.toHaveBeenCalled();
});
});
describe('upsertPreference key picking', () => {
it('does not inject spreadGap into a cbx per-book PUT', async () => {
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
await service.upsertPreference(makeUser(), 41, { direction: 'rtl' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 41, { direction: 'rtl' });
});
});
});
});
@@ -103,6 +103,29 @@ const PARTIAL_SETTINGS_SCHEMA_BY_GROUP = {
audio: AUDIO_SETTINGS_SCHEMA.partial(),
} satisfies Record<ReaderFormatGroup, z.ZodTypeAny>;
const SETTINGS_KEYS_BY_GROUP = {
epub: new Set(Object.keys(EPUB_SETTINGS_SCHEMA.shape)),
pdf: new Set(Object.keys(PDF_SETTINGS_SCHEMA.shape)),
cbx: new Set(Object.keys(CBX_SETTINGS_SCHEMA.shape)),
audio: new Set(Object.keys(AUDIO_SETTINGS_SCHEMA.shape)),
} satisfies Record<ReaderFormatGroup, Set<string>>;
/**
* Zod applies `.default()` while parsing a partial, so a cbx patch that never mentioned
* `spreadGap` comes back carrying one. Writing that would pin the key on the book. Keep only the
* keys the caller actually sent, while keeping the parsed value for each so schema transforms
* (the pdf `wrapped` -> `vertical` rewrite) survive.
*/
function keepProvidedKeys(input: Record<string, unknown>, parsed: Record<string, unknown>): Record<string, unknown> {
const kept: Record<string, unknown> = {};
for (const key of Object.keys(parsed)) {
if (Object.hasOwn(input, key)) {
kept[key] = parsed[key];
}
}
return kept;
}
function normalizeFormatGroup(formatGroup: string): ReaderFormatGroup {
const normalized = formatGroup.trim().toLowerCase();
if (!VALID_FORMAT_GROUPS_SET.has(normalized as ReaderFormatGroup)) {
@@ -130,6 +153,20 @@ export class ReaderPreferencesService {
return result.data as Record<string, unknown>;
}
/** Validates a sparse settings object and narrows it back to the keys the caller sent. */
private validatePartialSettings(formatGroup: ReaderFormatGroup, settings: Record<string, unknown>): Record<string, unknown> {
return keepProvidedKeys(settings, this.validateSettings(formatGroup, settings, true));
}
private validateUnsetKeys(formatGroup: ReaderFormatGroup, keys: string[]): string[] {
const known = SETTINGS_KEYS_BY_GROUP[formatGroup];
const unknown = keys.filter((key) => !known.has(key));
if (unknown.length > 0) {
throw new BadRequestException(`Invalid ${formatGroup} reader settings key in "unset": ${unknown.join(', ')}`);
}
return [...new Set(keys)];
}
async getPreference(user: RequestUser, bookFileId: number) {
await this.bookService.verifyFileAccess(bookFileId, user);
return this.repo.findPreference(user.id, bookFileId);
@@ -138,10 +175,31 @@ export class ReaderPreferencesService {
async upsertPreference(user: RequestUser, bookFileId: number, settings: Record<string, unknown>) {
const file = await this.bookService.verifyFileAccess(bookFileId, user);
const formatGroup = getFormatGroup(file.format ?? '');
const validatedSettings = this.validateSettings(formatGroup, settings, true);
const validatedSettings = this.validatePartialSettings(formatGroup, settings);
await this.repo.upsertPreference(user.id, bookFileId, validatedSettings);
}
async patchPreference(user: RequestUser, bookFileId: number, set?: Record<string, unknown>, unset?: string[]) {
const file = await this.bookService.verifyFileAccess(bookFileId, user);
const formatGroup = getFormatGroup(file.format ?? '');
const hasSet = !!set && Object.keys(set).length > 0;
const hasUnset = !!unset && unset.length > 0;
if (!hasSet && !hasUnset) {
throw new BadRequestException('Reader preference patch must carry at least one key in "set" or "unset"');
}
const validatedSet = hasSet ? this.validatePartialSettings(formatGroup, set) : {};
const validatedUnset = hasUnset ? this.validateUnsetKeys(formatGroup, unset) : [];
const conflicting = validatedUnset.filter((key) => Object.hasOwn(validatedSet, key));
if (conflicting.length > 0) {
throw new BadRequestException(`Reader preference patch sets and unsets the same key: ${conflicting.join(', ')}`);
}
await this.repo.patchPreference(user.id, bookFileId, validatedSet, validatedUnset);
}
async deletePreference(user: RequestUser, bookFileId: number) {
await this.bookService.verifyFileAccess(bookFileId, user);
await this.repo.deletePreference(user.id, bookFileId);
@@ -157,6 +215,15 @@ export class ReaderPreferencesService {
await this.repo.upsertDefault(userId, normalizedGroup, validatedSettings);
}
async patchDefault(userId: number, formatGroup: string, set?: Record<string, unknown>) {
const normalizedGroup = normalizeFormatGroup(formatGroup);
if (!set || Object.keys(set).length === 0) {
throw new BadRequestException('Reader defaults patch must carry at least one key in "set"');
}
const validatedSet = this.validatePartialSettings(normalizedGroup, set);
await this.repo.patchDefault(userId, normalizedGroup, { ...READER_GROUP_DEFAULTS[normalizedGroup] } as Record<string, unknown>, validatedSet);
}
async deleteDefault(userId: number, formatGroup: string) {
const normalizedGroup = normalizeFormatGroup(formatGroup);
await this.repo.deleteDefault(userId, normalizedGroup);