From 02262e3b3f6b7e7a5b23ce2e19eeb42ec479b16a Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 31 May 2026 15:48:01 +0000 Subject: [PATCH] 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 --- .../detail/tabs/EditMetadataTab.vue | 41 ++-- .../__tests__/useMetadataEditor.spec.ts | 33 +++ .../__tests__/useMetadataLocks.spec.ts | 33 +++ .../book/composables/useMetadataEditor.ts | 12 +- .../book/composables/useMetadataLocks.ts | 37 ++- .../book-metadata-lock.repository.ts | 5 +- .../book-metadata-lock.service.test.ts | 29 +++ .../book-metadata-lock.service.ts | 19 +- .../src/modules/book/book.controller.test.ts | 10 + server/src/modules/book/book.controller.ts | 13 + server/src/modules/book/book.service.test.ts | 73 ++++++ server/src/modules/book/book.service.ts | 226 +++++++++++------- .../dto/update-book-metadata-and-locks.dto.ts | 17 ++ ...update-book-metadata-and-locks.dto.test.ts | 35 +++ 14 files changed, 469 insertions(+), 114 deletions(-) create mode 100644 server/src/modules/book/dto/update-book-metadata-and-locks.dto.ts create mode 100644 server/src/modules/book/update-book-metadata-and-locks.dto.test.ts diff --git a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue index 52f9e3b95..ca25e0b8d 100644 --- a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue +++ b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue @@ -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(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) {