diff --git a/packages/types/src/reader-settings.ts b/packages/types/src/reader-settings.ts index 432b7d1e4..d16bf7772 100644 --- a/packages/types/src/reader-settings.ts +++ b/packages/types/src/reader-settings.ts @@ -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; +} + +/** + * 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; + unset?: string[]; +} + export const READER_GROUP_DEFAULTS: ReaderSettingsMap = { epub: EPUB_READER_DEFAULTS, pdf: PDF_READER_DEFAULTS, diff --git a/server/src/modules/reader-preferences/dto/patch-default.dto.ts b/server/src/modules/reader-preferences/dto/patch-default.dto.ts new file mode 100644 index 000000000..f6af7a248 --- /dev/null +++ b/server/src/modules/reader-preferences/dto/patch-default.dto.ts @@ -0,0 +1,7 @@ +import { IsObject, IsOptional } from 'class-validator'; + +export class PatchDefaultDto { + @IsObject() + @IsOptional() + set?: Record; +} diff --git a/server/src/modules/reader-preferences/dto/patch-preference.dto.ts b/server/src/modules/reader-preferences/dto/patch-preference.dto.ts new file mode 100644 index 000000000..dc81e9b1b --- /dev/null +++ b/server/src/modules/reader-preferences/dto/patch-preference.dto.ts @@ -0,0 +1,12 @@ +import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'; + +export class PatchPreferenceDto { + @IsObject() + @IsOptional() + set?: Record; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + unset?: string[]; +} diff --git a/server/src/modules/reader-preferences/reader-preferences.controller.test.ts b/server/src/modules/reader-preferences/reader-preferences.controller.test.ts index 964a99110..064a0d24e 100644 --- a/server/src/modules/reader-preferences/reader-preferences.controller.test.ts +++ b/server/src/modules/reader-preferences/reader-preferences.controller.test.ts @@ -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; 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 }); + }); }); diff --git a/server/src/modules/reader-preferences/reader-preferences.controller.ts b/server/src/modules/reader-preferences/reader-preferences.controller.ts index ae63f201c..7185b1412 100644 --- a/server/src/modules/reader-preferences/reader-preferences.controller.ts +++ b/server/src/modules/reader-preferences/reader-preferences.controller.ts @@ -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) { diff --git a/server/src/modules/reader-preferences/reader-preferences.repository.test.ts b/server/src/modules/reader-preferences/reader-preferences.repository.test.ts new file mode 100644 index 000000000..7927bf5c6 --- /dev/null +++ b/server/src/modules/reader-preferences/reader-preferences.repository.test.ts @@ -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); + }); + }); +}); diff --git a/server/src/modules/reader-preferences/reader-preferences.repository.ts b/server/src/modules/reader-preferences/reader-preferences.repository.ts index 03f12953e..b26cc4097 100644 --- a/server/src/modules/reader-preferences/reader-preferences.repository.ts +++ b/server/src/modules/reader-preferences/reader-preferences.repository.ts @@ -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; +/** 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, 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, set: Record) { + 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) diff --git a/server/src/modules/reader-preferences/reader-preferences.service.test.ts b/server/src/modules/reader-preferences/reader-preferences.service.test.ts index 0169a7959..5b98797e3 100644 --- a/server/src/modules/reader-preferences/reader-preferences.service.test.ts +++ b/server/src/modules/reader-preferences/reader-preferences.service.test.ts @@ -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 { return { @@ -54,6 +54,8 @@ const mockRepo = { findAllDefaults: vi.fn<(...args: [number]) => Promise }>>>(), upsertDefault: vi.fn<(...args: [number, string, Record]) => Promise>(), deleteDefault: vi.fn<(...args: [number, string]) => Promise>(), + patchPreference: vi.fn<(...args: [number, number, Record, string[]]) => Promise>(), + patchDefault: vi.fn<(...args: [number, string, Record, Record]) => Promise>(), }; 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 | 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 | 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' }); + }); + }); + }); }); diff --git a/server/src/modules/reader-preferences/reader-preferences.service.ts b/server/src/modules/reader-preferences/reader-preferences.service.ts index 4dda2e755..cf6b5bdb6 100644 --- a/server/src/modules/reader-preferences/reader-preferences.service.ts +++ b/server/src/modules/reader-preferences/reader-preferences.service.ts @@ -103,6 +103,29 @@ const PARTIAL_SETTINGS_SCHEMA_BY_GROUP = { audio: AUDIO_SETTINGS_SCHEMA.partial(), } satisfies Record; +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>; + +/** + * 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, parsed: Record): Record { + const kept: Record = {}; + 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; } + /** Validates a sparse settings object and narrows it back to the keys the caller sent. */ + private validatePartialSettings(formatGroup: ReaderFormatGroup, settings: Record): Record { + 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) { 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, 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) { + 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, validatedSet); + } + async deleteDefault(userId: number, formatGroup: string) { const normalizedGroup = normalizeFormatGroup(formatGroup); await this.repo.deleteDefault(userId, normalizedGroup);