fix(books): update metadata and locks atomically

When a newly filled metadata field is locked before saving, the subsequent
save causes a 409 conflict because the field was locked but not yet saved.
This introduces a new endpoint and DTO to update both metadata and locks
simultaneously, resolving the conflict.

Closes #172
This commit is contained in:
neon
2026-05-31 15:48:01 +00:00
parent 7a1b0120b8
commit 02262e3b3f
14 changed files with 469 additions and 114 deletions
@@ -80,13 +80,16 @@ const {
updating: updatingLocks,
error: lockError,
areAllLocked,
locksDirty,
load: loadLocks,
reset: resetLocks,
markPersisted: markLocksPersisted,
isLocked,
isUpdating: isUpdatingLock,
toggle,
lockAll,
unlockAll,
} = useMetadataLocks()
} = useMetadataLocks({ deferred: true })
const { search: searchAuthors } = useAuthorSearch()
const { search: searchNarrators } = useNarratorSearch()
const { search: searchGenres } = useGenreSearch()
@@ -142,14 +145,25 @@ watch(
const combinedError = computed(() => lockError.value ?? error.value)
const hasLockedFields = computed(() => lockedFields.value.length > 0)
const hasPendingChanges = computed(() => isDirty.value || locksDirty.value)
async function submit() {
if (coverPanel.value?.hasPending) {
const ok = await coverPanel.value.confirm()
if (ok) emit('coverChanged', 'custom')
}
const updated = await save(props.book.id)
if (updated) emit('saved', updated)
const shouldSaveLocks = locksDirty.value
const updated = await save(props.book.id, { saveLocks: shouldSaveLocks, lockedFields: lockedFields.value })
if (updated) {
markLocksPersisted(updated.lockedFields)
emit('saved', updated)
if (shouldSaveLocks) emit('locksChanged', updated.lockedFields)
}
}
function handleReset() {
reset()
resetLocks()
}
const hoverRating = ref<number | null>(null)
@@ -391,8 +405,7 @@ onBeforeUnmount(() => {
})
async function handleLockToggle(field: BookMetadataLockField) {
const updated = await toggle(props.book.id, field)
if (updated) emit('locksChanged', updated.lockedFields)
await toggle(props.book.id, field)
}
function handleCoverLockToggle() {
@@ -400,13 +413,11 @@ function handleCoverLockToggle() {
}
async function handleLockAll() {
const updated = await lockAll(props.book.id)
if (updated) emit('locksChanged', updated.lockedFields)
await lockAll(props.book.id)
}
async function handleUnlockAll() {
const updated = await unlockAll(props.book.id)
if (updated) emit('locksChanged', updated.lockedFields)
await unlockAll(props.book.id)
}
function handleCoverChanged(source: 'extracted' | 'custom' | null) {
@@ -530,15 +541,15 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
<button
class="flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
:disabled="!isDirty || saving"
@click="reset"
:disabled="!hasPendingChanges || saving"
@click="handleReset"
>
<X class="size-3.5" />
Cancel
</button>
<button
class="flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
:disabled="!isDirty || saving"
:disabled="!hasPendingChanges || saving"
@click="submit"
>
<Loader2 v-if="saving" class="size-3.5 animate-spin" />
@@ -1073,15 +1084,15 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<button
class="flex items-center gap-1.5 h-9 px-4 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
:disabled="!isDirty || saving"
@click="reset"
:disabled="!hasPendingChanges || saving"
@click="handleReset"
>
<X class="size-3.5" />
Cancel
</button>
<button
class="flex flex-1 items-center justify-center gap-1.5 h-9 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
:disabled="!isDirty || saving"
:disabled="!hasPendingChanges || saving"
@click="submit"
>
<Loader2 v-if="saving" class="size-3.5 animate-spin" />
@@ -127,4 +127,37 @@ describe('useMetadataEditor', () => {
publisher: 'Updated Publisher',
})
})
it('saves changed metadata and final locks through the atomic endpoint', async () => {
const book = makeBook({ providerIds: { goodreads: null } })
apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, lockedFields: ['goodreadsId'] }) })
const { form, load, save } = useMetadataEditor()
load(book)
form.goodreadsId = 'manual-goodreads-id'
await save(book.id, { saveLocks: true, lockedFields: ['goodreadsId'] })
const [url, req] = apiMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/v1/books/1/metadata-and-locks')
expect(JSON.parse(String(req.body))).toEqual({
metadata: { goodreadsId: 'manual-goodreads-id' },
lockedFields: ['goodreadsId'],
})
})
it('can save lock-only changes through the atomic endpoint without metadata fields', async () => {
const book = makeBook()
apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, lockedFields: ['title'] }) })
const { load, save } = useMetadataEditor()
load(book)
await save(book.id, { saveLocks: true, lockedFields: ['title'] })
const [url, req] = apiMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/v1/books/1/metadata-and-locks')
expect(JSON.parse(String(req.body))).toEqual({
metadata: {},
lockedFields: ['title'],
})
})
})
@@ -120,4 +120,37 @@ describe('useMetadataLocks', () => {
expect(result).toBeNull()
expect(error.value).toBe('HTTP 409')
})
it('stages lock changes without calling the API in deferred mode', async () => {
const { load, toggle, isLocked, locksDirty } = useMetadataLocks({ deferred: true })
load(makeBook())
await toggle(1, 'goodreadsId')
expect(apiMock).not.toHaveBeenCalled()
expect(isLocked('goodreadsId')).toBe(true)
expect(locksDirty.value).toBe(true)
})
it('resets deferred lock changes to the persisted lock state', async () => {
const { load, toggle, reset, lockedFields, locksDirty } = useMetadataLocks({ deferred: true })
load(makeBook({ lockedFields: ['title'] }))
await toggle(1, 'goodreadsId')
reset()
expect(lockedFields.value).toEqual(['title'])
expect(locksDirty.value).toBe(false)
})
it('marks deferred lock changes as persisted after a successful combined save', async () => {
const { load, toggle, markPersisted, lockedFields, locksDirty } = useMetadataLocks({ deferred: true })
load(makeBook())
await toggle(1, 'title')
markPersisted(['title'])
expect(lockedFields.value).toEqual(['title'])
expect(locksDirty.value).toBe(false)
})
})
@@ -1,6 +1,6 @@
import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { FORMAT_TO_GROUP, type BookDetail } from '@bookorbit/types'
import { FORMAT_TO_GROUP, type BookDetail, type BookMetadataLockField } from '@bookorbit/types'
const ROOT_FIELDS = [
'title',
@@ -181,14 +181,18 @@ export function useMetadataEditor() {
return payload
}
async function save(bookId: number): Promise<BookDetail | null> {
async function save(
bookId: number,
options: { lockedFields?: readonly BookMetadataLockField[]; saveLocks?: boolean } = {},
): Promise<BookDetail | null> {
saving.value = true
error.value = null
try {
const res = await api(`/api/v1/books/${bookId}/metadata`, {
const metadata = buildPayload()
const res = await api(options.saveLocks ? `/api/v1/books/${bookId}/metadata-and-locks` : `/api/v1/books/${bookId}/metadata`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload()),
body: JSON.stringify(options.saveLocks ? { metadata, lockedFields: options.lockedFields ?? [] } : metadata),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const updated: BookDetail = await res.json()
@@ -2,22 +2,46 @@ import { computed, ref } from 'vue'
import { api } from '@/lib/api'
import { BOOK_METADATA_LOCK_FIELDS, type BookDetail, type BookMetadataLockField } from '@bookorbit/types'
interface UseMetadataLocksOptions {
deferred?: boolean
}
function normalizeLockedFields(fields: readonly BookMetadataLockField[]): BookMetadataLockField[] {
const unique = new Set(fields)
return BOOK_METADATA_LOCK_FIELDS.filter((field) => unique.has(field))
}
export function useMetadataLocks() {
function lockKey(fields: readonly BookMetadataLockField[]): string {
return JSON.stringify(normalizeLockedFields(fields))
}
export function useMetadataLocks(options: UseMetadataLocksOptions = {}) {
const lockedFields = ref<BookMetadataLockField[]>([])
const persistedLockedFields = ref<BookMetadataLockField[]>([])
const updatingField = ref<BookMetadataLockField | 'all' | null>(null)
const error = ref<string | null>(null)
const updating = computed(() => updatingField.value !== null)
const lockedFieldSet = computed(() => new Set(lockedFields.value))
const areAllLocked = computed(() => BOOK_METADATA_LOCK_FIELDS.every((field) => lockedFieldSet.value.has(field)))
const locksDirty = computed(() => lockKey(lockedFields.value) !== lockKey(persistedLockedFields.value))
function load(book: BookDetail) {
lockedFields.value = normalizeLockedFields(book.lockedFields)
const normalized = normalizeLockedFields(book.lockedFields)
lockedFields.value = [...normalized]
persistedLockedFields.value = [...normalized]
error.value = null
}
function reset() {
lockedFields.value = [...persistedLockedFields.value]
error.value = null
}
function markPersisted(fields: readonly BookMetadataLockField[]) {
const normalized = normalizeLockedFields(fields)
lockedFields.value = [...normalized]
persistedLockedFields.value = [...normalized]
error.value = null
}
@@ -34,6 +58,10 @@ export function useMetadataLocks() {
error.value = null
try {
const normalized = normalizeLockedFields(nextLockedFields)
if (options.deferred) {
lockedFields.value = normalized
return null
}
const res = await api(`/api/v1/books/${bookId}/metadata-locks`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -41,7 +69,7 @@ export function useMetadataLocks() {
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const updated: BookDetail = await res.json()
lockedFields.value = normalizeLockedFields(updated.lockedFields)
markPersisted(updated.lockedFields)
return updated
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to update locks'
@@ -73,7 +101,10 @@ export function useMetadataLocks() {
updating,
error,
areAllLocked,
locksDirty,
load,
reset,
markPersisted,
isLocked,
isUpdating,
replace,
@@ -8,6 +8,7 @@ import { bookMetadata } from '../../db/schema';
import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type LockMutationExecutor = Pick<Db, 'insert'>;
@Injectable()
export class BookMetadataLockRepository {
@@ -31,9 +32,9 @@ export class BookMetadataLockRepository {
return new Map(rows.map((r) => [r.bookId, r.lockedFields ?? []]));
}
async replaceLockedFields(bookId: number, lockedFields: BookMetadataLockField[]): Promise<void> {
async replaceLockedFields(bookId: number, lockedFields: BookMetadataLockField[], executor: LockMutationExecutor = this.db): Promise<void> {
const now = new Date();
await this.db
await executor
.insert(bookMetadata)
.values({ bookId, lockedFields, updatedAt: now })
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { lockedFields, updatedAt: now } });
@@ -38,6 +38,35 @@ describe('BookMetadataLockService', () => {
await expect(service.assertManualUpdateAllowed(12, dto)).resolves.toBeUndefined();
});
it('allows metadata changes when the same request newly locks the targeted field', async () => {
const { service } = makeService([]);
await expect(service.assertManualUpdateAllowedForLockTransition(12, { goodreadsId: 'manual-id' }, ['goodreadsId'])).resolves.toBeUndefined();
});
it('allows metadata changes when the same request unlocks the targeted field', async () => {
const { service } = makeService(['publisher']);
await expect(service.assertManualUpdateAllowedForLockTransition(12, { publisher: 'Unlocked Publisher' }, [])).resolves.toBeUndefined();
});
it('rejects metadata changes for fields that stay locked across the request', async () => {
const { service } = makeService(['title', 'publisher']);
await expect(service.assertManualUpdateAllowedForLockTransition(12, { title: 'Still Locked' }, ['title'])).rejects.toThrow(
'Metadata fields are locked: title',
);
});
it('passes transaction executors through lock replacement', async () => {
const { service, lockRepo } = makeService();
const tx = { id: 'tx' };
await service.replaceLockedFields(12, ['cover'], tx as never);
expect(lockRepo.replaceLockedFields).toHaveBeenCalledWith(12, ['cover'], tx);
});
it('filters automated dto updates and preserves unlocked chapters', async () => {
const { service } = makeService(['title', 'narrators', 'comicIssueNumber', 'googleBooksId']);
@@ -64,9 +64,13 @@ export class BookMetadataLockService {
return fields.includes(field);
}
async replaceLockedFields(bookId: number, lockedFields: readonly string[]): Promise<BookMetadataLockField[]> {
async replaceLockedFields(
bookId: number,
lockedFields: readonly string[],
executor?: Parameters<BookMetadataLockRepository['replaceLockedFields']>[2],
): Promise<BookMetadataLockField[]> {
const normalized = this.normalizeLockedFields(lockedFields);
await this.lockRepo.replaceLockedFields(bookId, normalized);
await this.lockRepo.replaceLockedFields(bookId, normalized, executor);
return normalized;
}
@@ -109,6 +113,17 @@ export class BookMetadataLockService {
await this.assertFieldsUnlocked(bookId, this.getFieldsTargetedByBookUpdate(dto));
}
async assertManualUpdateAllowedForLockTransition(bookId: number, dto: UpdateBookMetadataDto, nextLockedFields: readonly string[]): Promise<void> {
const currentlyLocked = new Set(await this.getLockedFields(bookId));
const nextLocked = new Set(this.normalizeLockedFields(nextLockedFields));
const lockedBeforeAndAfter = [...currentlyLocked].filter((field) => nextLocked.has(field));
const targetedFields = this.getFieldsTargetedByBookUpdate(dto);
const blockedFields = targetedFields.filter((field) => lockedBeforeAndAfter.includes(field));
if (blockedFields.length === 0) return;
throw new ConflictException(`Metadata fields are locked: ${blockedFields.join(', ')}`);
}
async filterAutomatedBookUpdate(
bookId: number,
dto: UpdateBookMetadataDto,
@@ -140,6 +140,7 @@ function makeController() {
clearFileProgress: vi.fn(),
saveAudioProgress: vi.fn(),
updateMetadata: vi.fn(),
updateMetadataAndLocks: vi.fn(),
updateMetadataLocks: vi.fn(),
refreshMetadata: vi.fn(),
getMetadataFromFile: vi.fn(),
@@ -761,6 +762,7 @@ describe('BookController', () => {
const { controller, bookService } = makeController();
const user = makeUser();
bookService.updateMetadata.mockResolvedValue({ id: 7 });
bookService.updateMetadataAndLocks.mockResolvedValue({ id: 7, lockedFields: ['goodreadsId'] });
bookService.updateMetadataLocks.mockResolvedValue({ id: 7, lockedFields: ['title'] });
bookService.refreshMetadata.mockResolvedValue({ id: 7 });
bookService.getMetadataFromFile.mockResolvedValue({ title: 'File Title' });
@@ -769,6 +771,7 @@ describe('BookController', () => {
bookService.getDetail.mockResolvedValue({ id: 7, title: 'Detail' });
await controller.updateMetadata(7, { title: 'New' } as never, user);
await controller.updateMetadataAndLocks(7, { metadata: { goodreadsId: '123' }, lockedFields: ['goodreadsId'] } as never, user);
await controller.bulkSetMetadata({ bookIds: [7, 8], field: 'language', value: 'fr' } as never, user);
await controller.updateMetadataLocks(7, { lockedFields: ['title'] } as never, user);
await controller.refreshMetadata(7, 'true', user);
@@ -779,6 +782,7 @@ describe('BookController', () => {
await controller.getDetail(7, user);
expect(bookService.updateMetadata).toHaveBeenCalledWith(7, { title: 'New' }, user);
expect(bookService.updateMetadataAndLocks).toHaveBeenCalledWith(7, { metadata: { goodreadsId: '123' }, lockedFields: ['goodreadsId'] }, user);
expect(bookService.bulkSetMetadata).toHaveBeenCalledWith([7, 8], 'language', 'fr', user);
expect(bookService.updateMetadataLocks).toHaveBeenCalledWith(7, ['title'], user);
expect(bookService.refreshMetadata).toHaveBeenCalledWith(7, true, user);
@@ -816,6 +820,10 @@ describe('BookController', () => {
getResourceId: (req: { params: Record<string, string> }) => number;
description: (req: { params: Record<string, string> }) => string;
};
const updateMetadataAndLocksAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.updateMetadataAndLocks) as {
getResourceId: (req: { params: Record<string, string> }) => number;
description: (req: { params: Record<string, string> }) => string;
};
const updateLocksAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.updateMetadataLocks) as {
getResourceId: (req: { params: Record<string, string> }) => number;
description: (req: { params: Record<string, string> }) => string;
@@ -831,6 +839,8 @@ describe('BookController', () => {
expect(bulkSetMetadataAudit.description({ body: { bookIds: [1, 2], field: 'language' } })).toBe('Bulk set language for 2 books');
expect(updateMetadataAudit.getResourceId({ params: { id: '44' } })).toBe(44);
expect(updateMetadataAudit.description({ params: { id: '44' } })).toBe('Updated metadata for book #44');
expect(updateMetadataAndLocksAudit.getResourceId({ params: { id: '44' } })).toBe(44);
expect(updateMetadataAndLocksAudit.description({ params: { id: '44' } })).toBe('Updated metadata and locks for book #44');
expect(updateLocksAudit.getResourceId({ params: { id: '44' } })).toBe(44);
expect(updateLocksAudit.description({ params: { id: '44' } })).toBe('Updated metadata locks for book #44');
expect(refreshAudit.getResourceId({ params: { id: '44' } })).toBe(44);
@@ -43,6 +43,7 @@ import { ExportBooksDto } from './dto/export-books.dto';
import { MetadataExportDto } from './dto/metadata-export.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
import { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import { SearchBooksDto } from './dto/search-books.dto';
import { SetStatusDto } from '../user-book-status/dto/set-status.dto';
@@ -508,6 +509,18 @@ export class BookController {
return this.bookService.updateMetadata(id, dto, user);
}
@Patch(':id/metadata-and-locks')
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
action: AuditAction.BookMetadataUpdate,
resource: AuditResource.Book,
getResourceId: (req) => parseInt(req.params['id'] as string, 10),
description: (req) => `Updated metadata and locks for book #${req.params['id']}`,
})
updateMetadataAndLocks(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBookMetadataAndLocksDto, @CurrentUser() user: RequestUser) {
return this.bookService.updateMetadataAndLocks(id, dto, user);
}
@Patch(':id/metadata-locks')
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
@@ -175,6 +175,7 @@ function makeService() {
normalizeLockedFields: vi.fn().mockImplementation((fields: string[] | null | undefined) => fields ?? []),
isFieldLocked: vi.fn().mockResolvedValue(false),
assertManualUpdateAllowed: vi.fn().mockResolvedValue(undefined),
assertManualUpdateAllowedForLockTransition: vi.fn().mockResolvedValue(undefined),
filterResolvedMetadata: vi.fn().mockImplementation((_bookId: number, resolved: unknown, providerIds: unknown) =>
Promise.resolve({
resolved,
@@ -1159,6 +1160,78 @@ describe('BookService', () => {
expect(bookRepo.withTransaction).not.toHaveBeenCalled();
});
it('updateMetadataAndLocks persists edited metadata and final locks in one transaction', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
const tx = { id: 'tx-locks' };
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['goodreadsId'] } as never);
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
bookMetadataLockService.replaceLockedFields.mockResolvedValue(['goodreadsId']);
const result = await service.updateMetadataAndLocks(
5,
{
metadata: { goodreadsId: 'manual-goodreads-id' },
lockedFields: ['goodreadsId'],
},
user,
);
expect(bookMetadataLockService.assertManualUpdateAllowedForLockTransition).toHaveBeenCalledWith(5, { goodreadsId: 'manual-goodreads-id' }, [
'goodreadsId',
]);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
5,
expect.objectContaining({
goodreadsId: 'manual-goodreads-id',
updatedAt: expect.any(Date),
}),
tx,
);
expect(bookMetadataLockService.replaceLockedFields).toHaveBeenCalledWith(5, ['goodreadsId'], tx);
expect(result).toEqual({ id: 5, lockedFields: ['goodreadsId'] });
});
it('updateMetadataAndLocks rejects fields locked before and after without opening a transaction', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
const error = new ConflictException('Metadata fields are locked: title');
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookMetadataLockService.assertManualUpdateAllowedForLockTransition.mockRejectedValue(error);
await expect(
service.updateMetadataAndLocks(
5,
{
metadata: { title: 'Blocked Title' },
lockedFields: ['title'],
},
user,
),
).rejects.toThrow(error);
expect(bookRepo.withTransaction).not.toHaveBeenCalled();
expect(bookMetadataLockService.replaceLockedFields).not.toHaveBeenCalled();
});
it('updateMetadataAndLocks can persist locks without scheduling metadata side effects', async () => {
const { service, bookRepo, bookMetadataLockService, embedder, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['title'] } as never);
bookMetadataLockService.replaceLockedFields.mockResolvedValue(['title']);
await service.updateMetadataAndLocks(5, { metadata: {}, lockedFields: ['title'] }, user);
expect(bookRepo.updateMetadataFields).not.toHaveBeenCalled();
expect(bookMetadataLockService.replaceLockedFields).toHaveBeenCalledWith(5, ['title'], expect.anything());
expect(embedder.embedBook).not.toHaveBeenCalled();
expect(fileWriteService.scheduleWrite).not.toHaveBeenCalled();
expect(fileRenameService.scheduleRename).not.toHaveBeenCalled();
expect(scoreService.calculateAndSave).not.toHaveBeenCalled();
});
it('updateMetadata does not clear omitted scalar fields on transformed dto instances', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
+138 -88
View File
@@ -74,6 +74,7 @@ import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto
import { SaveProgressDto } from './dto/save-progress.dto';
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import type { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
import { buildBookDetailSupplementalFields } from './utils/build-book-detail-supplemental-fields';
import type { SetStatusDto } from '../user-book-status/dto/set-status.dto';
@@ -1108,94 +1109,7 @@ export class BookService {
try {
await this.verifyBookAccess(id, user);
await this.bookMetadataLockService.assertManualUpdateAllowed(id, dto);
const scalarFields: Parameters<BookRepository['updateMetadataFields']>[1] = {};
if (dto.title !== undefined) scalarFields.title = dto.title ?? null;
if (dto.subtitle !== undefined) scalarFields.subtitle = dto.subtitle ?? null;
if (dto.description !== undefined) scalarFields.description = dto.description ?? null;
if (dto.publisher !== undefined) scalarFields.publisher = dto.publisher ?? null;
if (dto.publishedYear !== undefined) scalarFields.publishedYear = dto.publishedYear ?? null;
if (dto.language !== undefined) scalarFields.language = dto.language ?? null;
if (dto.pageCount !== undefined) scalarFields.pageCount = dto.pageCount ?? null;
if (dto.seriesName !== undefined) scalarFields.seriesName = dto.seriesName ?? null;
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null;
if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
if (dto.hardcoverId !== undefined) scalarFields.hardcoverId = dto.hardcoverId ?? null;
if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null;
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
if (dto.audioMetadata) {
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
if (dto.audioMetadata.abridged !== undefined) scalarFields.abridged = dto.audioMetadata.abridged ?? false;
if (dto.audioMetadata.chapters !== undefined) scalarFields.chapters = dto.audioMetadata.chapters ?? null;
}
const scalarFieldCount = Object.keys(scalarFields).length;
let replacedAuthorIds: number[] = [];
await this.bookRepo.withTransaction(async (tx) => {
if (scalarFieldCount > 0) {
scalarFields.updatedAt = new Date();
await this.bookRepo.updateMetadataFields(id, scalarFields, tx);
}
this.throwIfMetadataUpdateFailpoint('afterScalarUpdate');
if (dto.comicMetadata) {
await this.comicMetadataService.upsert(id, dto.comicMetadata, tx);
}
this.throwIfMetadataUpdateFailpoint('afterComicMetadataUpsert');
if (dto.authors !== undefined) {
replacedAuthorIds = await this.metadataService.replaceAuthors(
id,
dto.authors.map((name) => ({ name, sortName: null })),
{ executor: tx, emitEvent: false },
);
}
this.throwIfMetadataUpdateFailpoint('afterAuthorsReplace');
if (dto.audioMetadata?.narrators !== undefined) {
await this.narratorService.replaceForBook(id, dto.audioMetadata.narrators, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterNarratorsReplace');
if (dto.genres !== undefined) {
await this.metadataService.replaceGenres(id, dto.genres, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterGenresReplace');
if (dto.tags !== undefined) {
await this.metadataService.replaceTags(id, dto.tags, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterTagsReplace');
this.throwIfMetadataUpdateFailpoint('beforeTransactionCommit');
});
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
if (dto.rating !== undefined) {
const rating = dto.rating ?? null;
await this.bookRepo.bulkSetRating([id], rating, user.id);
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [id],
rating,
});
}
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
const hasRenameRelevantField = Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
if (hasRenameRelevantField) {
this.fileRenameService?.scheduleRename(id, user.id);
}
this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`));
const detail = await this.getDetail(id, user);
const { detail, scalarFieldCount } = await this.persistMetadataUpdate(id, dto, user);
this.logger.log(
`[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} authorsUpdated=${dto.authors !== undefined} narratorsUpdated=${dto.audioMetadata?.narrators !== undefined} genresUpdated=${dto.genres !== undefined} tagsUpdated=${dto.tags !== undefined} audioMetadataUpdated=${dto.audioMetadata !== undefined} comicMetadataUpdated=${dto.comicMetadata !== undefined} - metadata update completed`,
);
@@ -1210,6 +1124,142 @@ export class BookService {
}
}
async updateMetadataAndLocks(id: number, dto: UpdateBookMetadataAndLocksDto, user: RequestUser): Promise<BookDetailDto> {
const event = 'book.update_metadata_and_locks';
const startedAt = Date.now();
const metadata = dto.metadata ?? {};
this.logger.log(
`[${event}] [start] bookId=${id} userId=${user.id} metadataFields=${Object.keys(metadata).length} lockFields=${dto.lockedFields.length} - metadata and lock update started`,
);
try {
await this.verifyBookAccess(id, user);
await this.bookMetadataLockService.assertManualUpdateAllowedForLockTransition(id, metadata, dto.lockedFields);
const { detail, scalarFieldCount, normalizedLockedFields } = await this.persistMetadataUpdate(id, metadata, user, {
lockedFields: dto.lockedFields,
});
this.logger.log(
`[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} lockFields=${normalizedLockedFields?.length ?? 0} - metadata and lock update completed`,
);
return detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata and lock update failed`,
);
throw err;
}
}
private async persistMetadataUpdate(
id: number,
dto: UpdateBookMetadataDto,
user: RequestUser,
options: { lockedFields?: readonly string[] } = {},
): Promise<{ detail: BookDetailDto; scalarFieldCount: number; normalizedLockedFields?: BookMetadataLockField[] }> {
const scalarFields: Parameters<BookRepository['updateMetadataFields']>[1] = {};
if (dto.title !== undefined) scalarFields.title = dto.title ?? null;
if (dto.subtitle !== undefined) scalarFields.subtitle = dto.subtitle ?? null;
if (dto.description !== undefined) scalarFields.description = dto.description ?? null;
if (dto.publisher !== undefined) scalarFields.publisher = dto.publisher ?? null;
if (dto.publishedYear !== undefined) scalarFields.publishedYear = dto.publishedYear ?? null;
if (dto.language !== undefined) scalarFields.language = dto.language ?? null;
if (dto.pageCount !== undefined) scalarFields.pageCount = dto.pageCount ?? null;
if (dto.seriesName !== undefined) scalarFields.seriesName = dto.seriesName ?? null;
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null;
if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
if (dto.hardcoverId !== undefined) scalarFields.hardcoverId = dto.hardcoverId ?? null;
if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null;
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
if (dto.audioMetadata) {
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
if (dto.audioMetadata.abridged !== undefined) scalarFields.abridged = dto.audioMetadata.abridged ?? false;
if (dto.audioMetadata.chapters !== undefined) scalarFields.chapters = dto.audioMetadata.chapters ?? null;
}
const scalarFieldCount = Object.keys(scalarFields).length;
const hasMetadataUpdate = Object.keys(dto).length > 0;
let replacedAuthorIds: number[] = [];
let normalizedLockedFields: BookMetadataLockField[] | undefined;
await this.bookRepo.withTransaction(async (tx) => {
if (scalarFieldCount > 0) {
scalarFields.updatedAt = new Date();
await this.bookRepo.updateMetadataFields(id, scalarFields, tx);
}
this.throwIfMetadataUpdateFailpoint('afterScalarUpdate');
if (dto.comicMetadata) {
await this.comicMetadataService.upsert(id, dto.comicMetadata, tx);
}
this.throwIfMetadataUpdateFailpoint('afterComicMetadataUpsert');
if (dto.authors !== undefined) {
replacedAuthorIds = await this.metadataService.replaceAuthors(
id,
dto.authors.map((name) => ({ name, sortName: null })),
{ executor: tx, emitEvent: false },
);
}
this.throwIfMetadataUpdateFailpoint('afterAuthorsReplace');
if (dto.audioMetadata?.narrators !== undefined) {
await this.narratorService.replaceForBook(id, dto.audioMetadata.narrators, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterNarratorsReplace');
if (dto.genres !== undefined) {
await this.metadataService.replaceGenres(id, dto.genres, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterGenresReplace');
if (dto.tags !== undefined) {
await this.metadataService.replaceTags(id, dto.tags, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterTagsReplace');
if (options.lockedFields !== undefined) {
normalizedLockedFields = await this.bookMetadataLockService.replaceLockedFields(id, options.lockedFields, tx);
}
this.throwIfMetadataUpdateFailpoint('beforeTransactionCommit');
});
if (dto.authors !== undefined) {
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
}
if (dto.rating !== undefined) {
const rating = dto.rating ?? null;
await this.bookRepo.bulkSetRating([id], rating, user.id);
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [id],
rating,
});
}
if (hasMetadataUpdate) {
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
const hasRenameRelevantField = Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
if (hasRenameRelevantField) {
this.fileRenameService?.scheduleRename(id, user.id);
}
this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`));
}
const detail = await this.getDetail(id, user);
return { detail, scalarFieldCount, normalizedLockedFields };
}
async updateMetadataLocks(id: number, lockedFields: string[], user: RequestUser): Promise<BookDetailDto> {
const event = 'book.update_metadata_locks';
const startedAt = Date.now();
@@ -0,0 +1,17 @@
import { Type } from 'class-transformer';
import { IsArray, IsIn, IsOptional, ValidateNested, ArrayUnique } from 'class-validator';
import { BOOK_METADATA_LOCK_FIELDS, type BookMetadataLockField } from '@bookorbit/types';
import { UpdateBookMetadataDto } from './update-book-metadata.dto';
export class UpdateBookMetadataAndLocksDto {
@IsOptional()
@ValidateNested()
@Type(() => UpdateBookMetadataDto)
metadata?: UpdateBookMetadataDto;
@IsArray()
@ArrayUnique()
@IsIn(BOOK_METADATA_LOCK_FIELDS, { each: true })
lockedFields!: BookMetadataLockField[];
}
@@ -0,0 +1,35 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
describe('UpdateBookMetadataAndLocksDto', () => {
it('accepts metadata updates with a final lock set', async () => {
const dto = plainToInstance(UpdateBookMetadataAndLocksDto, {
metadata: { goodreadsId: 'manual-id' },
lockedFields: ['goodreadsId'],
});
expect(await validate(dto)).toHaveLength(0);
});
it('accepts lock-only updates with empty metadata', async () => {
const dto = plainToInstance(UpdateBookMetadataAndLocksDto, {
metadata: {},
lockedFields: ['title'],
});
expect(await validate(dto)).toHaveLength(0);
});
it('rejects invalid metadata fields and invalid lock fields', async () => {
const dto = plainToInstance(UpdateBookMetadataAndLocksDto, {
metadata: { publishedYear: 42 },
lockedFields: ['not-a-field'],
});
expect(await validate(dto)).not.toHaveLength(0);
});
});