mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(server): resolve bulk selection queries for collections and email
Both AddToCollection and SendBook previously only operated on the explicitly provided list of book IDs. This prevented them from working correctly when a user selected all items across multiple pages in the UI. They now inherit from BulkSelectionDto and resolve queries to apply to all matching books. Closes #416
This commit is contained in:
@@ -112,7 +112,7 @@ vi.mock('@/features/book/components/BookCoverPlaceholder.vue', () => ({
|
||||
vi.mock('@/features/email/components/SendBookDialog.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'SendBookDialog',
|
||||
props: ['open', 'bookIds', 'bookFiles', 'bookTitle'],
|
||||
props: ['open', 'selectionPayload', 'bookFiles', 'bookTitle'],
|
||||
emits: ['update:open'],
|
||||
setup() {
|
||||
return () => h('div', { 'data-testid': 'send-dialog' })
|
||||
|
||||
@@ -832,7 +832,8 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
<SendBookDialog
|
||||
v-if="showSendDialog"
|
||||
:open="showSendDialog"
|
||||
:book-ids="[book.id]"
|
||||
:selection-payload="{ bookIds: [book.id] }"
|
||||
:selected-count="1"
|
||||
:book-files="book.files"
|
||||
:book-title="book.title ?? undefined"
|
||||
@update:open="showSendDialog = $event"
|
||||
|
||||
@@ -323,7 +323,8 @@ function handleRowClick(event: MouseEvent) {
|
||||
<SendBookDialog
|
||||
v-if="showSendDialog"
|
||||
:open="showSendDialog"
|
||||
:book-ids="[book.id]"
|
||||
:selection-payload="{ bookIds: [book.id] }"
|
||||
:selected-count="1"
|
||||
:book-files="book.files"
|
||||
:book-title="book.title ?? undefined"
|
||||
@update:open="showSendDialog = $event"
|
||||
|
||||
@@ -16,6 +16,12 @@ import DetailsTab from '../detail/tabs/DetailsTab.vue'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const COLLECTION_MEMBERSHIP_REQUEST = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: [1] }),
|
||||
}
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return {
|
||||
@@ -180,7 +186,7 @@ describe('DetailsTab - present state', () => {
|
||||
|
||||
it('fetches collections data for Kobo sync but does not display them', async () => {
|
||||
vi.mocked(api).mockImplementation(async (input) => {
|
||||
if (input === '/api/v1/collections?bookIds=1') {
|
||||
if (input === '/api/v1/collections/membership') {
|
||||
return makeApiResponse([
|
||||
{ id: 10, name: 'Favorites', syncToKobo: false, memberCount: 1 },
|
||||
{ id: 11, name: 'Want to Read', syncToKobo: true, memberCount: 0 },
|
||||
@@ -197,7 +203,7 @@ describe('DetailsTab - present state', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api)).toHaveBeenCalledWith('/api/v1/collections?bookIds=1')
|
||||
expect(vi.mocked(api)).toHaveBeenCalledWith('/api/v1/collections/membership', COLLECTION_MEMBERSHIP_REQUEST)
|
||||
expect(wrapper.text()).not.toContain('Favorites')
|
||||
expect(wrapper.text()).not.toContain('Collections')
|
||||
})
|
||||
@@ -225,7 +231,7 @@ describe('DetailsTab - present state', () => {
|
||||
updatedAt: '2026-04-11T00:00:00.000Z',
|
||||
})
|
||||
}
|
||||
if (input === '/api/v1/collections?bookIds=1') return makeApiResponse([])
|
||||
if (input === '/api/v1/collections/membership') return makeApiResponse([])
|
||||
return makeApiResponse({}, false)
|
||||
})
|
||||
|
||||
@@ -267,7 +273,7 @@ describe('DetailsTab - present state', () => {
|
||||
|
||||
it('shows validation for finished date earlier than started date and blocks save', async () => {
|
||||
vi.mocked(api).mockImplementation(async (input) => {
|
||||
if (input === '/api/v1/collections?bookIds=1') return makeApiResponse([])
|
||||
if (input === '/api/v1/collections/membership') return makeApiResponse([])
|
||||
return makeApiResponse({}, false)
|
||||
})
|
||||
|
||||
@@ -305,7 +311,7 @@ describe('DetailsTab - present state', () => {
|
||||
if (input === '/api/v1/books/1/progress') {
|
||||
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 0.4, updatedAt: null }])
|
||||
}
|
||||
if (input === '/api/v1/collections?bookIds=1') {
|
||||
if (input === '/api/v1/collections/membership') {
|
||||
return makeApiResponse([])
|
||||
}
|
||||
return makeApiResponse({}, false)
|
||||
@@ -341,7 +347,7 @@ describe('DetailsTab - present state', () => {
|
||||
if (input === '/api/v1/books/1/progress') {
|
||||
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 99.6, updatedAt: null }])
|
||||
}
|
||||
if (input === '/api/v1/collections?bookIds=1') {
|
||||
if (input === '/api/v1/collections/membership') {
|
||||
return makeApiResponse([])
|
||||
}
|
||||
return makeApiResponse({}, false)
|
||||
@@ -384,7 +390,7 @@ describe('DetailsTab - present state', () => {
|
||||
if (input === '/api/v1/books/1/progress') {
|
||||
return makeApiResponse(progressRows)
|
||||
}
|
||||
if (input === '/api/v1/collections?bookIds=1') {
|
||||
if (input === '/api/v1/collections/membership') {
|
||||
return makeApiResponse([])
|
||||
}
|
||||
return makeApiResponse({}, false)
|
||||
@@ -666,7 +672,7 @@ describe('DetailsTab - reading dates compact display', () => {
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
})
|
||||
}
|
||||
if (input === '/api/v1/collections?bookIds=1') return makeApiResponse([])
|
||||
if (input === '/api/v1/collections/membership') return makeApiResponse([])
|
||||
return makeApiResponse({}, false)
|
||||
})
|
||||
|
||||
|
||||
@@ -864,7 +864,11 @@ async function loadSupplemental() {
|
||||
try {
|
||||
const progressPromise = api(`/api/v1/books/${props.book.id}/progress`).catch(() => null)
|
||||
const audioProgressPromise = hasAudio ? api(`/api/v1/books/${props.book.id}/audio-progress`).catch(() => null) : Promise.resolve(null)
|
||||
const collectionsPromise = api(`/api/v1/collections?bookIds=${props.book.id}`)
|
||||
const collectionsPromise = api('/api/v1/collections/membership', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: [props.book.id] }),
|
||||
})
|
||||
const koboPromise = canViewKobo.value ? api(`/api/v1/books/${props.book.id}/kobo-state`) : Promise.resolve(null)
|
||||
const koreaderProgressPromise = canViewKoreader.value ? fetchKoreaderProgress(props.book.id) : Promise.resolve()
|
||||
|
||||
@@ -1815,12 +1819,19 @@ watch(
|
||||
|
||||
<AddToCollectionSheet
|
||||
:open="addToCollectionOpen"
|
||||
:book-ids="[book.id]"
|
||||
:selection-payload="{ bookIds: [book.id] }"
|
||||
:selected-count="1"
|
||||
@update:open="addToCollectionOpen = $event"
|
||||
@done="void loadSupplemental()"
|
||||
/>
|
||||
|
||||
<SendBookDialog v-model:open="showSendDialog" :book-ids="[book.id]" :book-title="book.title ?? undefined" :book-files="book.files" />
|
||||
<SendBookDialog
|
||||
v-model:open="showSendDialog"
|
||||
:selection-payload="{ bookIds: [book.id] }"
|
||||
:selected-count="1"
|
||||
:book-title="book.title ?? undefined"
|
||||
:book-files="book.files"
|
||||
/>
|
||||
|
||||
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
|
||||
|
||||
@@ -98,5 +98,11 @@ async function handleRefresh() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<SendBookDialog v-if="showSendDialog" :book-ids="[book.id]" :open="showSendDialog" @update:open="showSendDialog = $event" />
|
||||
<SendBookDialog
|
||||
v-if="showSendDialog"
|
||||
:selection-payload="{ bookIds: [book.id] }"
|
||||
:selected-count="1"
|
||||
:open="showSendDialog"
|
||||
@update:open="showSendDialog = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -196,5 +196,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<SendBookDialog v-if="book && showSendDialog" :book-ids="[book.id]" :open="showSendDialog" @update:open="showSendDialog = $event" />
|
||||
<SendBookDialog
|
||||
v-if="book && showSendDialog"
|
||||
:selection-payload="{ bookIds: [book.id] }"
|
||||
:selected-count="1"
|
||||
:open="showSendDialog"
|
||||
@update:open="showSendDialog = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -128,6 +128,26 @@ describe('useBookBulkActions', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('exposes explicit and query selection payloads for external bulk dialogs', () => {
|
||||
const selectedIds = ref(new Set([10, 20]))
|
||||
const inactiveQuerySelection = ref<QuerySelectionState | null>(null)
|
||||
const explicit = useBookBulkActions(selectedIds, vi.fn(), undefined, undefined, inactiveQuerySelection)
|
||||
|
||||
expect(explicit.getSelectionPayload()).toEqual({ bookIds: [10, 20] })
|
||||
|
||||
const querySelection = ref<QuerySelectionState | null>(makeQuerySelection({ sort: [{ field: 'title', dir: 'asc' }] }))
|
||||
const queryScoped = useBookBulkActions(ref(new Set<number>()), vi.fn(), undefined, undefined, querySelection)
|
||||
|
||||
expect(queryScoped.getSelectionPayload()).toEqual({
|
||||
query: {
|
||||
libraryId: 5,
|
||||
filter: { type: 'group', join: 'AND', rules: [] },
|
||||
q: 'space opera',
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('patches selected books with the new manual read status after a successful bulk update', async () => {
|
||||
mocks.api.mockResolvedValue({ ok: true })
|
||||
const selectedIds = ref(new Set([1, 2]))
|
||||
|
||||
@@ -2,12 +2,12 @@ import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import {
|
||||
BOOK_METADATA_LOCK_FIELDS,
|
||||
type BookSelectionPayload,
|
||||
type BookSelectionQuery,
|
||||
type BookCard,
|
||||
type BookDetail,
|
||||
type BookMetadataLockField,
|
||||
type GroupRule,
|
||||
type ReadStatus,
|
||||
type SortSpec,
|
||||
type UserBookStatus,
|
||||
} from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -56,11 +56,7 @@ const BULK_FIELD_LOCK_FIELD: Record<BulkEditableField, BookMetadataLockField> =
|
||||
|
||||
export type InFlightOp = { label: string; processed: number; total: number; failed?: number }
|
||||
|
||||
export type QuerySelectionState = {
|
||||
libraryId?: number
|
||||
filter?: GroupRule
|
||||
q?: string
|
||||
sort?: SortSpec[]
|
||||
export type QuerySelectionState = BookSelectionQuery & {
|
||||
total: number
|
||||
}
|
||||
|
||||
@@ -88,7 +84,7 @@ export function useBookBulkActions(
|
||||
|
||||
const inFlight = ref<InFlightOp | null>(null)
|
||||
|
||||
function getSelectionPayload(): { bookIds: number[] } | { query: Omit<QuerySelectionState, 'total'> } {
|
||||
function getSelectionPayload(): BookSelectionPayload {
|
||||
if (querySelection?.value) {
|
||||
const { libraryId, filter, q, sort } = querySelection.value
|
||||
return { query: { libraryId, filter, q, sort } }
|
||||
@@ -423,5 +419,6 @@ export function useBookBulkActions(
|
||||
handleBulkSetField,
|
||||
handleBulkSetMetadataLock,
|
||||
handleDeleteSelected,
|
||||
getSelectionPayload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCollections } from '../composables/useCollections'
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
import type { Collection } from '@bookorbit/types'
|
||||
import type { BookSelectionPayload, Collection } from '@bookorbit/types'
|
||||
import { useVirtualKeyboard } from '@/composables/useVirtualKeyboard'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
bookIds: number[]
|
||||
selectionPayload: BookSelectionPayload
|
||||
selectedCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -30,6 +31,11 @@ const newIcon = ref('')
|
||||
const creating = ref(false)
|
||||
const mutatingCollectionId = ref<number | null>(null)
|
||||
|
||||
function selectionCount(): number {
|
||||
const bookIds = props.selectionPayload.bookIds
|
||||
return props.selectedCount ?? (Array.isArray(bookIds) ? bookIds.length : 0)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
@@ -37,7 +43,7 @@ watch(
|
||||
changedCollectionIds.value = new Set()
|
||||
mutatingCollectionId.value = null
|
||||
try {
|
||||
localCollections.value = await fetchCollectionsWithMembership(props.bookIds)
|
||||
localCollections.value = await fetchCollectionsWithMembership(props.selectionPayload)
|
||||
} catch {
|
||||
toast.error('Failed to load collections')
|
||||
}
|
||||
@@ -48,11 +54,12 @@ watch(
|
||||
|
||||
function membershipCount(collection: Collection): number {
|
||||
const count = collection.memberCount ?? 0
|
||||
return Math.max(0, Math.min(props.bookIds.length, count))
|
||||
return Math.max(0, Math.min(selectionCount(), count))
|
||||
}
|
||||
|
||||
function isFullyAdded(collection: Collection): boolean {
|
||||
return props.bookIds.length > 0 && membershipCount(collection) === props.bookIds.length
|
||||
const total = selectionCount()
|
||||
return total > 0 && membershipCount(collection) === total
|
||||
}
|
||||
|
||||
function partialCount(collection: Collection): number {
|
||||
@@ -64,7 +71,7 @@ function justAdded(collection: Collection): boolean {
|
||||
}
|
||||
|
||||
function membershipLabel(collection: Collection): string {
|
||||
const total = props.bookIds.length
|
||||
const total = selectionCount()
|
||||
if (isFullyAdded(collection)) {
|
||||
if (justAdded(collection)) return total === 1 ? 'Added' : `All ${total} added`
|
||||
return total === 1 ? 'In this collection' : `All ${total} in this collection`
|
||||
@@ -89,10 +96,11 @@ async function handleCreate() {
|
||||
newIcon.value = ''
|
||||
localCollections.value = [...localCollections.value, { ...collection, memberCount: 0 }]
|
||||
try {
|
||||
await addBooksToCollection(collection.id, props.bookIds)
|
||||
localCollections.value = localCollections.value.map((c) => (c.id === collection.id ? { ...c, memberCount: props.bookIds.length } : c))
|
||||
await addBooksToCollection(collection.id, props.selectionPayload)
|
||||
const total = selectionCount()
|
||||
localCollections.value = localCollections.value.map((c) => (c.id === collection.id ? { ...c, memberCount: total } : c))
|
||||
markCollectionChanged(collection.id)
|
||||
toast.success(`Created "${collection.name}" and added ${props.bookIds.length} book${props.bookIds.length === 1 ? '' : 's'}`)
|
||||
toast.success(`Created "${collection.name}" and added ${total} book${total === 1 ? '' : 's'}`)
|
||||
} catch {
|
||||
toast.error(`Created "${collection.name}" but failed to add books`)
|
||||
}
|
||||
@@ -104,18 +112,19 @@ async function handleCreate() {
|
||||
}
|
||||
|
||||
async function handleAddTo(collection: Collection) {
|
||||
if (isFullyAdded(collection) || props.bookIds.length === 0 || mutatingCollectionId.value === collection.id) return
|
||||
const total = selectionCount()
|
||||
if (isFullyAdded(collection) || total === 0 || mutatingCollectionId.value === collection.id) return
|
||||
mutatingCollectionId.value = collection.id
|
||||
try {
|
||||
await addBooksToCollection(collection.id, props.bookIds)
|
||||
await addBooksToCollection(collection.id, props.selectionPayload)
|
||||
markCollectionChanged(collection.id)
|
||||
localCollections.value = localCollections.value.map((c) => (c.id === collection.id ? { ...c, memberCount: props.bookIds.length } : c))
|
||||
localCollections.value = localCollections.value.map((c) => (c.id === collection.id ? { ...c, memberCount: total } : c))
|
||||
const alreadyHad = membershipCount(collection)
|
||||
const added = props.bookIds.length - alreadyHad
|
||||
const added = total - alreadyHad
|
||||
const msg =
|
||||
alreadyHad > 0
|
||||
? `Added ${added} new book${added === 1 ? '' : 's'} to "${collection.name}" (${alreadyHad} already there)`
|
||||
: `Added ${props.bookIds.length} book${props.bookIds.length === 1 ? '' : 's'} to "${collection.name}"`
|
||||
: `Added ${total} book${total === 1 ? '' : 's'} to "${collection.name}"`
|
||||
toast.success(msg)
|
||||
} catch {
|
||||
toast.error('Failed to add to collection')
|
||||
@@ -125,13 +134,14 @@ async function handleAddTo(collection: Collection) {
|
||||
}
|
||||
|
||||
async function handleRemoveFrom(collection: Collection) {
|
||||
if (!isFullyAdded(collection) || props.bookIds.length === 0 || mutatingCollectionId.value === collection.id) return
|
||||
const total = selectionCount()
|
||||
if (!isFullyAdded(collection) || total === 0 || mutatingCollectionId.value === collection.id) return
|
||||
mutatingCollectionId.value = collection.id
|
||||
try {
|
||||
await removeBooksFromCollection(collection.id, props.bookIds)
|
||||
await removeBooksFromCollection(collection.id, props.selectionPayload)
|
||||
markCollectionChanged(collection.id)
|
||||
localCollections.value = localCollections.value.map((c) => (c.id === collection.id ? { ...c, memberCount: 0 } : c))
|
||||
toast.success(`Removed ${props.bookIds.length} book${props.bookIds.length === 1 ? '' : 's'} from "${collection.name}"`)
|
||||
toast.success(`Removed ${total} book${total === 1 ? '' : 's'} from "${collection.name}"`)
|
||||
} catch {
|
||||
toast.error('Failed to remove from collection')
|
||||
} finally {
|
||||
@@ -184,7 +194,7 @@ function handleFocusOut(e: FocusEvent) {
|
||||
</SheetHeader>
|
||||
|
||||
<div class="px-4 pb-4 space-y-4">
|
||||
<p class="text-xs text-muted-foreground animate-fade-up">{{ bookIds.length }} book{{ bookIds.length === 1 ? '' : 's' }} selected</p>
|
||||
<p class="text-xs text-muted-foreground animate-fade-up">{{ selectionCount() }} book{{ selectionCount() === 1 ? '' : 's' }} selected</p>
|
||||
|
||||
<!-- Create new collection -->
|
||||
<div class="space-y-2 animate-fade-up" style="animation-delay: 50ms">
|
||||
@@ -215,7 +225,7 @@ function handleFocusOut(e: FocusEvent) {
|
||||
? 'hover:bg-destructive/10 cursor-pointer'
|
||||
: 'hover:bg-muted cursor-pointer'
|
||||
"
|
||||
:disabled="bookIds.length === 0 || mutatingCollectionId === collection.id"
|
||||
:disabled="selectionCount() === 0 || mutatingCollectionId === collection.id"
|
||||
@click="handleCollectionAction(collection)"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Collection } from '@bookorbit/types'
|
||||
import type { BookSelectionPayload, Collection } from '@bookorbit/types'
|
||||
import AddToCollectionSheet from '../AddToCollectionSheet.vue'
|
||||
|
||||
const {
|
||||
@@ -13,10 +13,10 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
toastSuccess: vi.fn<(message: string) => void>(),
|
||||
toastError: vi.fn<(message: string) => void>(),
|
||||
fetchCollectionsWithMembershipMock: vi.fn<(bookIds: number[]) => Promise<Collection[]>>(),
|
||||
fetchCollectionsWithMembershipMock: vi.fn<(selectionPayload: BookSelectionPayload) => Promise<Collection[]>>(),
|
||||
createCollectionMock: vi.fn<(name: string, icon: string) => Promise<Collection>>(),
|
||||
addBooksToCollectionMock: vi.fn<(collectionId: number, bookIds: number[]) => Promise<Collection>>(),
|
||||
removeBooksFromCollectionMock: vi.fn<(collectionId: number, bookIds: number[]) => Promise<Collection>>(),
|
||||
addBooksToCollectionMock: vi.fn<(collectionId: number, selectionPayload: BookSelectionPayload) => Promise<Collection>>(),
|
||||
removeBooksFromCollectionMock: vi.fn<(collectionId: number, selectionPayload: BookSelectionPayload) => Promise<Collection>>(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
@@ -80,11 +80,12 @@ const globalStubs = {
|
||||
},
|
||||
}
|
||||
|
||||
function mountSheet(props: { open?: boolean; bookIds?: number[] } = {}) {
|
||||
function mountSheet(props: { open?: boolean; selectionPayload?: BookSelectionPayload; selectedCount?: number } = {}) {
|
||||
return mount(AddToCollectionSheet, {
|
||||
props: {
|
||||
open: props.open ?? true,
|
||||
bookIds: props.bookIds ?? [1],
|
||||
selectionPayload: props.selectionPayload ?? { bookIds: [1] },
|
||||
selectedCount: props.selectedCount,
|
||||
},
|
||||
global: globalStubs,
|
||||
})
|
||||
@@ -112,7 +113,7 @@ describe('AddToCollectionSheet', () => {
|
||||
const wrapper = mountSheet()
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchCollectionsWithMembershipMock).toHaveBeenCalledWith([1])
|
||||
expect(fetchCollectionsWithMembershipMock).toHaveBeenCalledWith({ bookIds: [1] })
|
||||
expect(wrapper.text()).toContain('Read soon')
|
||||
})
|
||||
|
||||
@@ -135,7 +136,7 @@ describe('AddToCollectionSheet', () => {
|
||||
await findButtonByText(wrapper, 'Queue').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(addBooksToCollectionMock).toHaveBeenCalledWith(2, [1])
|
||||
expect(addBooksToCollectionMock).toHaveBeenCalledWith(2, { bookIds: [1] })
|
||||
expect(removeBooksFromCollectionMock).not.toHaveBeenCalled()
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Added 1 book to "Queue"')
|
||||
|
||||
@@ -155,7 +156,7 @@ describe('AddToCollectionSheet', () => {
|
||||
await findButtonByText(wrapper, 'Finished').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(removeBooksFromCollectionMock).toHaveBeenCalledWith(3, [1])
|
||||
expect(removeBooksFromCollectionMock).toHaveBeenCalledWith(3, { bookIds: [1] })
|
||||
expect(addBooksToCollectionMock).not.toHaveBeenCalled()
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Removed 1 book from "Finished"')
|
||||
|
||||
@@ -199,12 +200,31 @@ describe('AddToCollectionSheet', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(createCollectionMock).toHaveBeenCalledWith('New Shelf', 'FolderOpen')
|
||||
expect(addBooksToCollectionMock).toHaveBeenCalledWith(12, [1])
|
||||
expect(addBooksToCollectionMock).toHaveBeenCalledWith(12, { bookIds: [1] })
|
||||
|
||||
await findButtonByText(wrapper, 'Done').trigger('click')
|
||||
expect(wrapper.emitted('done')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses query-scoped payloads and selectedCount for all-matching selections', async () => {
|
||||
const selectionPayload: BookSelectionPayload = { query: { libraryId: 5, q: 'dune', sort: [{ field: 'title', dir: 'asc' }] } }
|
||||
fetchCollectionsWithMembershipMock.mockResolvedValue([makeCollection({ id: 20, name: 'All Dune', memberCount: 100, bookCount: 500 })])
|
||||
addBooksToCollectionMock.mockResolvedValue(makeCollection({ id: 20, name: 'All Dune', bookCount: 1833 }))
|
||||
|
||||
const wrapper = mountSheet({ selectionPayload, selectedCount: 1833 })
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchCollectionsWithMembershipMock).toHaveBeenCalledWith(selectionPayload)
|
||||
expect(wrapper.text()).toContain('1833 books selected')
|
||||
expect(wrapper.text()).toContain('100 of 1833 in this collection')
|
||||
|
||||
await findButtonByText(wrapper, 'All Dune').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(addBooksToCollectionMock).toHaveBeenCalledWith(20, selectionPayload)
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Added 1733 new books to "All Dune" (100 already there)')
|
||||
})
|
||||
|
||||
it('does not emit done when no membership changes occurred', async () => {
|
||||
fetchCollectionsWithMembershipMock.mockResolvedValue([makeCollection({ id: 14, name: 'No-op', memberCount: 0 })])
|
||||
|
||||
|
||||
@@ -66,16 +66,20 @@ describe('useCollections', () => {
|
||||
expect(collections.value).toEqual([])
|
||||
})
|
||||
|
||||
it('fetches collections with per-book membership counts', async () => {
|
||||
it('fetches collections with per-selection membership counts', async () => {
|
||||
const withMembership = [makeCollection({ id: 3, memberCount: 1 })]
|
||||
apiMock.mockResolvedValueOnce(makeResponse(withMembership))
|
||||
|
||||
const { useCollections } = await import('../useCollections')
|
||||
const { fetchCollectionsWithMembership } = useCollections()
|
||||
|
||||
const result = await fetchCollectionsWithMembership([7, 8])
|
||||
const result = await fetchCollectionsWithMembership({ query: { libraryId: 5, q: 'dune' } })
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/collections?bookIds=7,8')
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/collections/membership', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: { libraryId: 5, q: 'dune' } }),
|
||||
})
|
||||
expect(result).toEqual(withMembership)
|
||||
})
|
||||
|
||||
@@ -88,7 +92,7 @@ describe('useCollections', () => {
|
||||
const { collections, createCollection, removeBooksFromCollection } = useCollections()
|
||||
|
||||
await createCollection(created.name, created.icon ?? 'FolderOpen')
|
||||
const result = await removeBooksFromCollection(created.id, [7])
|
||||
const result = await removeBooksFromCollection(created.id, { bookIds: [7] })
|
||||
|
||||
expect(apiMock).toHaveBeenLastCalledWith(`/api/v1/collections/${created.id}/books`, {
|
||||
method: 'DELETE',
|
||||
@@ -105,6 +109,6 @@ describe('useCollections', () => {
|
||||
const { useCollections } = await import('../useCollections')
|
||||
const { removeBooksFromCollection } = useCollections()
|
||||
|
||||
await expect(removeBooksFromCollection(1, [9])).rejects.toThrow('Failed to remove books from collection')
|
||||
await expect(removeBooksFromCollection(1, { bookIds: [9] })).rejects.toThrow('Failed to remove books from collection')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Collection } from '@bookorbit/types'
|
||||
import type { BookSelectionPayload, Collection } from '@bookorbit/types'
|
||||
|
||||
const collections = ref<Collection[]>([])
|
||||
const loaded = ref(false)
|
||||
@@ -32,8 +32,12 @@ export function useCollections() {
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
async function fetchCollectionsWithMembership(bookIds: number[]): Promise<Collection[]> {
|
||||
const res = await api(`/api/v1/collections?bookIds=${bookIds.join(',')}`)
|
||||
async function fetchCollectionsWithMembership(selectionPayload: BookSelectionPayload): Promise<Collection[]> {
|
||||
const res = await api('/api/v1/collections/membership', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(selectionPayload),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to fetch collections')
|
||||
return res.json()
|
||||
}
|
||||
@@ -62,11 +66,11 @@ export function useCollections() {
|
||||
return updated
|
||||
}
|
||||
|
||||
async function addBooksToCollection(collectionId: number, bookIds: number[]): Promise<Collection> {
|
||||
async function addBooksToCollection(collectionId: number, selectionPayload: BookSelectionPayload): Promise<Collection> {
|
||||
const res = await api(`/api/v1/collections/${collectionId}/books`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds }),
|
||||
body: JSON.stringify(selectionPayload),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to add books to collection')
|
||||
const updated: Collection = await res.json()
|
||||
@@ -74,11 +78,11 @@ export function useCollections() {
|
||||
return updated
|
||||
}
|
||||
|
||||
async function removeBooksFromCollection(collectionId: number, bookIds: number[]): Promise<Collection> {
|
||||
async function removeBooksFromCollection(collectionId: number, selectionPayload: BookSelectionPayload): Promise<Collection> {
|
||||
const res = await api(`/api/v1/collections/${collectionId}/books`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds }),
|
||||
body: JSON.stringify(selectionPayload),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to remove books from collection')
|
||||
const updated: Collection = await res.json()
|
||||
|
||||
@@ -44,9 +44,9 @@ vi.mock('@/features/book/components/BookQuickView.vue', () => ({
|
||||
vi.mock('@/features/collection/components/AddToCollectionSheet.vue', () => ({
|
||||
default: {
|
||||
name: 'AddToCollectionSheet',
|
||||
props: ['open', 'bookIds'],
|
||||
props: ['open', 'selectionPayload'],
|
||||
emits: ['update:open'],
|
||||
template: '<div class="collection-sheet" :data-open="String(open)" :data-book-ids="bookIds.join(\',\')" />',
|
||||
template: '<div class="collection-sheet" :data-open="String(open)" :data-book-ids="(selectionPayload.bookIds || []).join(\',\')" />',
|
||||
},
|
||||
}))
|
||||
vi.mock('@/features/book/components/DeleteBookDialog.vue', () => ({
|
||||
|
||||
@@ -175,7 +175,8 @@ function coverAspectRatio(book: BookCard): CoverAspectRatio {
|
||||
|
||||
<AddToCollectionSheet
|
||||
:open="addToCollectionOpen"
|
||||
:book-ids="addToCollectionBookId ? [addToCollectionBookId] : []"
|
||||
:selection-payload="{ bookIds: addToCollectionBookId ? [addToCollectionBookId] : [] }"
|
||||
:selected-count="addToCollectionBookId ? 1 : 0"
|
||||
@update:open="addToCollectionOpen = $event"
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEmailRecipients } from '../composables/useEmailRecipients'
|
||||
import { useEmailGroups } from '../composables/useEmailGroups'
|
||||
import { useEmailTemplates } from '../composables/useEmailTemplates'
|
||||
import { useEmailSend } from '../composables/useEmailSend'
|
||||
import type { BookSelectionPayload } from '@bookorbit/types'
|
||||
|
||||
interface BookFile {
|
||||
id: number
|
||||
@@ -16,7 +17,8 @@ interface BookFile {
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
bookIds: number[]
|
||||
selectionPayload: BookSelectionPayload
|
||||
selectedCount?: number
|
||||
bookFiles?: BookFile[]
|
||||
bookTitle?: string
|
||||
}>()
|
||||
@@ -41,6 +43,11 @@ const sending = ref(false)
|
||||
|
||||
const hasSelection = computed(() => selectedRecipientIds.value.length > 0 || selectedGroupIds.value.length > 0)
|
||||
|
||||
function selectionCount(): number {
|
||||
const bookIds = props.selectionPayload.bookIds
|
||||
return props.selectedCount ?? (Array.isArray(bookIds) ? bookIds.length : 0)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
@@ -82,7 +89,7 @@ async function send() {
|
||||
sending.value = true
|
||||
try {
|
||||
const result = await sendBook({
|
||||
bookIds: props.bookIds,
|
||||
...props.selectionPayload,
|
||||
recipientIds: selectedRecipientIds.value.length > 0 ? selectedRecipientIds.value : undefined,
|
||||
groupIds: selectedGroupIds.value.length > 0 ? selectedGroupIds.value : undefined,
|
||||
providerId: selectedProviderId.value ?? undefined,
|
||||
@@ -116,7 +123,7 @@ function close() {
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-foreground">Send via Email</h2>
|
||||
<p v-if="bookTitle" class="text-xs text-muted-foreground mt-0.5 truncate max-w-[280px]">{{ bookTitle }}</p>
|
||||
<p v-else-if="bookIds.length > 1" class="text-xs text-muted-foreground mt-0.5">{{ bookIds.length }} books</p>
|
||||
<p v-else-if="selectionCount() > 1" class="text-xs text-muted-foreground mt-0.5">{{ selectionCount() }} books</p>
|
||||
</div>
|
||||
<button class="text-muted-foreground hover:text-foreground transition-colors" @click="close()">
|
||||
<X :size="16" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookSelectionPayload } from '@bookorbit/types'
|
||||
import SendBookDialog from '../SendBookDialog.vue'
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
@@ -58,12 +59,18 @@ vi.mock('vue-sonner', () => ({
|
||||
}))
|
||||
|
||||
function mountDialog(
|
||||
overrides: Partial<{ open: boolean; bookIds: number[]; bookFiles: Array<{ id: number; format: string | null; role: string }> }> = {},
|
||||
overrides: Partial<{
|
||||
open: boolean
|
||||
selectionPayload: BookSelectionPayload
|
||||
selectedCount: number
|
||||
bookFiles: Array<{ id: number; format: string | null; role: string }>
|
||||
}> = {},
|
||||
) {
|
||||
return mount(SendBookDialog, {
|
||||
props: {
|
||||
open: true,
|
||||
bookIds: [12],
|
||||
selectionPayload: { bookIds: [12] },
|
||||
selectedCount: 1,
|
||||
...overrides,
|
||||
},
|
||||
global: {
|
||||
@@ -118,7 +125,8 @@ describe('SendBookDialog', () => {
|
||||
it('sends selected recipients and groups, then emits close and sent', async () => {
|
||||
const wrapper = mountDialog({
|
||||
open: true,
|
||||
bookIds: [12, 13],
|
||||
selectionPayload: { bookIds: [12, 13] },
|
||||
selectedCount: 2,
|
||||
bookFiles: [
|
||||
{ id: 101, format: 'epub', role: 'primary' },
|
||||
{ id: 102, format: 'pdf', role: 'secondary' },
|
||||
@@ -174,6 +182,28 @@ describe('SendBookDialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sends query-scoped selection payloads for all-matching selections', async () => {
|
||||
const selectionPayload: BookSelectionPayload = { query: { libraryId: 5, q: 'dune', sort: [{ field: 'title', dir: 'asc' }] } }
|
||||
const wrapper = mountDialog({ open: true, selectionPayload, selectedCount: 1833 })
|
||||
|
||||
expect(wrapper.text()).toContain('1833 books')
|
||||
|
||||
const recipientCheckbox = wrapper.find('input[type="checkbox"]')
|
||||
await recipientCheckbox.trigger('change')
|
||||
|
||||
const sendButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Send')
|
||||
await sendButton!.trigger('click')
|
||||
|
||||
expect(mockState.sendBook).toHaveBeenCalledWith({
|
||||
...selectionPayload,
|
||||
recipientIds: [10],
|
||||
groupIds: undefined,
|
||||
providerId: undefined,
|
||||
templateId: undefined,
|
||||
fileId: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a toast error when send fails', async () => {
|
||||
mockState.sendBook.mockRejectedValueOnce(new Error('Failed to send'))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookSelectionPayload } from '@bookorbit/types'
|
||||
|
||||
export interface SendBookPayload {
|
||||
bookIds: number[]
|
||||
export type SendBookPayload = BookSelectionPayload & {
|
||||
recipientIds?: number[]
|
||||
groupIds?: number[]
|
||||
fileId?: number
|
||||
|
||||
@@ -194,12 +194,17 @@ const AddToCollectionSheetStub = defineComponent({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
bookIds: {
|
||||
type: Array as PropType<number[]>,
|
||||
selectionPayload: {
|
||||
type: Object as PropType<{ bookIds?: number[] }>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:open'],
|
||||
computed: {
|
||||
bookIds(): number[] {
|
||||
return this.selectionPayload.bookIds ?? []
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div data-testid="collection-sheet" :data-open="open ? 'true' : 'false'" :data-book-ids="bookIds.join(',')">
|
||||
<button data-testid="collection-sheet-close" @click="$emit('update:open', false)">close</button>
|
||||
|
||||
@@ -694,7 +694,8 @@ watch(
|
||||
|
||||
<AddToCollectionSheet
|
||||
:open="addToCollectionOpen"
|
||||
:book-ids="addToCollectionBookId ? [addToCollectionBookId] : []"
|
||||
:selection-payload="{ bookIds: addToCollectionBookId ? [addToCollectionBookId] : [] }"
|
||||
:selected-count="addToCollectionBookId ? 1 : 0"
|
||||
@update:open="handleAddToCollectionOpenChange"
|
||||
/>
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ async function handleRemoveFromCollection() {
|
||||
removingInProgress = true
|
||||
try {
|
||||
const ids = [...selectedIds.value]
|
||||
await removeBooksFromCollection(collectionId.value, ids)
|
||||
await removeBooksFromCollection(collectionId.value, { bookIds: ids })
|
||||
resetBooks()
|
||||
refreshBuckets()
|
||||
exitSelectionMode()
|
||||
@@ -388,7 +388,8 @@ watch(collectionId, async () => {
|
||||
|
||||
<AddToCollectionSheet
|
||||
:open="addToCollectionOpen"
|
||||
:book-ids="[...selectedIds]"
|
||||
:selection-payload="{ bookIds: [...selectedIds] }"
|
||||
:selected-count="selectedCount"
|
||||
@update:open="addToCollectionOpen = $event"
|
||||
@done="exitSelectionMode"
|
||||
/>
|
||||
@@ -407,7 +408,13 @@ watch(collectionId, async () => {
|
||||
@close="editCollectionOpen = false"
|
||||
@deleted="handleCollectionDeleted"
|
||||
/>
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
<SendBookDialog
|
||||
:open="sendBookOpen"
|
||||
:selection-payload="{ bookIds: [...selectedIds] }"
|
||||
:selected-count="selectedCount"
|
||||
@update:open="sendBookOpen = $event"
|
||||
@sent="exitSelectionMode"
|
||||
/>
|
||||
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
|
||||
<section class="flex h-full flex-col">
|
||||
|
||||
@@ -398,6 +398,7 @@ const {
|
||||
handleBulkSetField,
|
||||
handleBulkSetMetadataLock,
|
||||
handleDeleteSelected,
|
||||
getSelectionPayload,
|
||||
addToCollectionOpen,
|
||||
bulkEditOpen,
|
||||
sendBookOpen,
|
||||
@@ -1017,7 +1018,8 @@ async function handleToggleCollapse() {
|
||||
|
||||
<AddToCollectionSheet
|
||||
:open="addToCollectionOpen"
|
||||
:book-ids="[...selectedIds]"
|
||||
:selection-payload="getSelectionPayload()"
|
||||
:selected-count="querySelection ? querySelection.total : selectedCount"
|
||||
@update:open="addToCollectionOpen = $event"
|
||||
@done="exitSelectionMode"
|
||||
/>
|
||||
@@ -1030,7 +1032,13 @@ async function handleToggleCollapse() {
|
||||
@confirm="handleBulkEditConfirm"
|
||||
/>
|
||||
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
<SendBookDialog
|
||||
:open="sendBookOpen"
|
||||
:selection-payload="getSelectionPayload()"
|
||||
:selected-count="querySelection ? querySelection.total : selectedCount"
|
||||
@update:open="sendBookOpen = $event"
|
||||
@sent="exitSelectionMode"
|
||||
/>
|
||||
|
||||
<SaveAsSmartScopeDialog :open="saveAsSmartScopeOpen" :filter="filter" :sort="sort" @close="saveAsSmartScopeOpen = false" />
|
||||
|
||||
|
||||
@@ -407,7 +407,8 @@ watch(smartScopeId, async () => {
|
||||
|
||||
<AddToCollectionSheet
|
||||
:open="addToCollectionOpen"
|
||||
:book-ids="[...selectedIds]"
|
||||
:selection-payload="{ bookIds: [...selectedIds] }"
|
||||
:selected-count="selectedCount"
|
||||
@update:open="addToCollectionOpen = $event"
|
||||
@done="exitSelectionMode"
|
||||
/>
|
||||
@@ -418,7 +419,13 @@ watch(smartScopeId, async () => {
|
||||
@update:open="bulkEditOpen = $event"
|
||||
@confirm="handleBulkEditConfirm"
|
||||
/>
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
<SendBookDialog
|
||||
:open="sendBookOpen"
|
||||
:selection-payload="{ bookIds: [...selectedIds] }"
|
||||
:selected-count="selectedCount"
|
||||
@update:open="sendBookOpen = $event"
|
||||
@sent="exitSelectionMode"
|
||||
/>
|
||||
|
||||
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { GroupRule, SortSpec } from "./query";
|
||||
|
||||
export type BookSelectionQuery = {
|
||||
libraryId?: number;
|
||||
filter?: GroupRule;
|
||||
q?: string;
|
||||
sort?: SortSpec[];
|
||||
};
|
||||
|
||||
export type BookSelectionPayload = { bookIds: number[]; query?: never } | { query: BookSelectionQuery; bookIds?: never };
|
||||
@@ -8,6 +8,7 @@ export * from "./metadata-preferences";
|
||||
export * from "./metadata-lock";
|
||||
export * from "./dashboard";
|
||||
export * from "./book";
|
||||
export * from "./book-selection";
|
||||
export * from "./audiobook";
|
||||
export * from "./upload";
|
||||
export * from "./collection";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsInt, IsObject, IsOptional, IsString, Min, ValidateIf, ValidateNested } from 'class-validator';
|
||||
|
||||
import type { GroupRule, SortSpec } from '@bookorbit/types';
|
||||
|
||||
export class BulkQuerySelectionDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
libraryId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filter?: GroupRule;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObject({ each: true })
|
||||
sort?: SortSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection DTO shared by bulk operations that can target either explicit book
|
||||
* IDs or the current "all matching books" query selection.
|
||||
*/
|
||||
export class BulkSelectionDto {
|
||||
@ValidateIf((dto: BulkSelectionDto) => !dto.query)
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
bookIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BulkQuerySelectionDto)
|
||||
query?: BulkQuerySelectionDto;
|
||||
}
|
||||
@@ -1656,7 +1656,10 @@ describe('BookService', () => {
|
||||
const user = makeUser();
|
||||
const onProgress = vi.fn();
|
||||
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 1, libraryId: 7 },
|
||||
{ id: 2, libraryId: 7 },
|
||||
]);
|
||||
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
|
||||
{ bookId: 1, absolutePath: '/books/1.epub', format: 'epub' },
|
||||
{ bookId: 2, absolutePath: '/books/2.epub', format: 'epub' },
|
||||
@@ -1676,7 +1679,10 @@ describe('BookService', () => {
|
||||
const { service, bookRepo, metadataService } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 1, libraryId: 7 },
|
||||
{ id: 2, libraryId: 7 },
|
||||
]);
|
||||
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
|
||||
{ bookId: 1, absolutePath: '/books/1.epub', format: 'epub' },
|
||||
{ bookId: 2, absolutePath: '/books/2.epub', format: 'epub' },
|
||||
@@ -1718,7 +1724,11 @@ describe('BookService', () => {
|
||||
const refreshSpy = vi.spyOn(service, 'refreshMetadata').mockResolvedValue({ id: 1 } as never);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 1, libraryId: 7 },
|
||||
{ id: 2, libraryId: 7 },
|
||||
{ id: 3, libraryId: 7 },
|
||||
]);
|
||||
|
||||
const result = await service.bulkRefreshMetadata([1, 2, 3], user, onProgress, {
|
||||
isCancelled: () => refreshSpy.mock.calls.length > 0,
|
||||
@@ -1734,7 +1744,10 @@ describe('BookService', () => {
|
||||
const user = makeUser();
|
||||
const refreshSpy = vi.spyOn(service, 'refreshMetadata').mockResolvedValue({ id: 1 } as never);
|
||||
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 1, libraryId: 7 },
|
||||
{ id: 2, libraryId: 7 },
|
||||
]);
|
||||
|
||||
const onProgress = vi
|
||||
.fn()
|
||||
@@ -2627,7 +2640,10 @@ describe('BookService', () => {
|
||||
it('bulkRefreshMetadata increments failed count when individual books fail', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
const user = makeUser();
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 1, libraryId: 7 },
|
||||
{ id: 2, libraryId: 7 },
|
||||
]);
|
||||
const refreshSpy = vi
|
||||
.spyOn(service, 'refreshMetadata')
|
||||
.mockRejectedValueOnce(new Error('failed one'))
|
||||
@@ -3872,6 +3888,14 @@ describe('BookService', () => {
|
||||
await expect(service.resolveSelectionToIds({ bookIds: [1, 2] }, makeUser())).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rejects explicit selections when any requested book id is missing', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 5 }]);
|
||||
|
||||
await expect(service.resolveSelectionToIds({ bookIds: [1, 2] }, makeUser())).rejects.toThrow(NotFoundException);
|
||||
expect(bookRepo.findLibraryIdsByBookIds).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
|
||||
it('resolves query selections across all accessible libraries', async () => {
|
||||
const { service, bookRepo, libraryService, queryBuilder } = makeService();
|
||||
const user = makeUser({ id: 42 });
|
||||
|
||||
@@ -78,7 +78,7 @@ import { ComicMetadataRepository } from '../metadata/comic-metadata.repository';
|
||||
import { BookDetailDto } from './dto/book-detail.dto';
|
||||
import type { BulkMetadataField } from './dto/bulk-set-metadata.dto';
|
||||
import type { BulkEditFieldsDto } from './dto/bulk-edit-metadata.dto';
|
||||
import type { BulkSelectionDto } from './dto/bulk-selection.dto';
|
||||
import type { BulkSelectionDto } from '../../common/dto/bulk-selection.dto';
|
||||
import type { MetadataExportDto, MetadataExportFormat, MetadataExportViewType } from './dto/metadata-export.dto';
|
||||
import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto';
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
@@ -259,7 +259,11 @@ export class BookService {
|
||||
}
|
||||
|
||||
private async verifyLibraryAccessForBookIds(bookIds: number[], user: RequestUser): Promise<{ id: number; libraryId: number }[]> {
|
||||
const rows = await this.bookRepo.findLibraryIdsByBookIds(bookIds);
|
||||
const uniqueBookIds = [...new Set(bookIds)];
|
||||
const rows = await this.bookRepo.findLibraryIdsByBookIds(uniqueBookIds);
|
||||
if (rows.length !== uniqueBookIds.length) {
|
||||
throw new NotFoundException('One or more books were not found');
|
||||
}
|
||||
const uniqueLibraryIds = [...new Set(rows.map((row) => row.libraryId))];
|
||||
const isSuperuser = this.isSuperuser(user);
|
||||
await Promise.all(uniqueLibraryIds.map((libraryId) => this.libraryService.verifyUserAccess(user.id, libraryId, isSuperuser)));
|
||||
|
||||
@@ -42,7 +42,9 @@ describe('Book DTO validation', () => {
|
||||
expect((await errorsFor(BulkSelectionDto, { query: {} })).length).toBe(0);
|
||||
expect((await errorsFor(BulkSelectionDto, {})).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(BulkSelectionDto, { bookIds: [] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(BulkSelectionDto, { bookIds: [0] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(BulkSelectionDto, { bookIds: ['x'] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(BulkQuerySelectionDto, { libraryId: 0 })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates bulk book id DTOs in explicit and query modes', async () => {
|
||||
|
||||
@@ -1,22 +1 @@
|
||||
import { IsArray, IsInt, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import type { GroupRule } from '@bookorbit/types';
|
||||
import type { SortSpec } from '@bookorbit/types';
|
||||
|
||||
export class BulkQuerySelectionDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
libraryId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filter?: GroupRule;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObject({ each: true })
|
||||
sort?: SortSpec[];
|
||||
}
|
||||
export { BulkQuerySelectionDto } from '../../../common/dto/bulk-selection.dto';
|
||||
|
||||
@@ -1,24 +1 @@
|
||||
import { ArrayNotEmpty, IsArray, IsInt, IsOptional, ValidateIf, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { BulkQuerySelectionDto } from './bulk-query-selection.dto';
|
||||
|
||||
/**
|
||||
* Selection DTO used by all bulk book operations.
|
||||
*
|
||||
* Exactly one of `bookIds` or `query` must be provided:
|
||||
* - `bookIds` - explicit list of book IDs (standard selection)
|
||||
* - `query` - filter/scope that is resolved to IDs server-side at execution time
|
||||
* (used for cross-page "select all N matching books" mode)
|
||||
*/
|
||||
export class BulkSelectionDto {
|
||||
@ValidateIf((dto: BulkSelectionDto) => !dto.query)
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
bookIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BulkQuerySelectionDto)
|
||||
query?: BulkQuerySelectionDto;
|
||||
}
|
||||
export { BulkSelectionDto } from '../../../common/dto/bulk-selection.dto';
|
||||
|
||||
@@ -28,6 +28,7 @@ const USER: RequestUser = {
|
||||
function makeController() {
|
||||
const service = {
|
||||
findAll: vi.fn(),
|
||||
findAllWithSelectionMembership: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
create: vi.fn(),
|
||||
reorder: vi.fn(),
|
||||
@@ -80,6 +81,16 @@ describe('CollectionController', () => {
|
||||
await expectBadRequest(() => controller.findAll(USER, '0,2'));
|
||||
await expectBadRequest(() => controller.findAll(USER, ' '));
|
||||
});
|
||||
|
||||
it('delegates membership payloads to the selection-aware service method', async () => {
|
||||
const { controller, service } = makeController();
|
||||
const selection = { query: { libraryId: 5, q: 'dune' } };
|
||||
service.findAllWithSelectionMembership.mockResolvedValue([]);
|
||||
|
||||
await controller.findAllWithMembership(selection, USER);
|
||||
|
||||
expect(service.findAllWithSelectionMembership).toHaveBeenCalledWith(selection, USER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBooks', () => {
|
||||
@@ -190,11 +201,11 @@ describe('CollectionController', () => {
|
||||
};
|
||||
const addBooksMeta = Reflect.getMetadata(AUDITABLE_KEY, CollectionController.prototype.addBooks) as {
|
||||
getResourceId: (req: { params: { id: string } }) => number;
|
||||
description: (req: { params: { id: string }; body: { bookIds?: number[] } }) => string;
|
||||
description: (req: { params: { id: string }; body: { bookIds?: number[]; query?: unknown } }) => string;
|
||||
};
|
||||
const removeBooksMeta = Reflect.getMetadata(AUDITABLE_KEY, CollectionController.prototype.removeBooks) as {
|
||||
getResourceId: (req: { params: { id: string } }) => number;
|
||||
description: (req: { params: { id: string }; body: { bookIds?: number[] } }) => string;
|
||||
description: (req: { params: { id: string }; body: { bookIds?: number[]; query?: unknown } }) => string;
|
||||
};
|
||||
|
||||
expect(createMeta.description({} as never, { name: 'Favorites' })).toBe("Created collection 'Favorites'");
|
||||
@@ -204,9 +215,13 @@ describe('CollectionController', () => {
|
||||
expect(removeMeta.description({ params: { id: '10' } })).toBe('Deleted collection #10');
|
||||
expect(addBooksMeta.getResourceId({ params: { id: '10' } })).toBe(10);
|
||||
expect(addBooksMeta.description({ params: { id: '10' }, body: { bookIds: [1, 2] } })).toBe('Added 2 books to collection #10');
|
||||
expect(addBooksMeta.description({ params: { id: '10' }, body: { query: { libraryId: 5 } } })).toBe('Added all matching books to collection #10');
|
||||
expect(addBooksMeta.description({ params: { id: '10' }, body: {} })).toBe('Added 0 books to collection #10');
|
||||
expect(removeBooksMeta.getResourceId({ params: { id: '10' } })).toBe(10);
|
||||
expect(removeBooksMeta.description({ params: { id: '10' }, body: { bookIds: [1] } })).toBe('Removed 1 book from collection #10');
|
||||
expect(removeBooksMeta.description({ params: { id: '10' }, body: { query: { libraryId: 5 } } })).toBe(
|
||||
'Removed all matching books from collection #10',
|
||||
);
|
||||
expect(removeBooksMeta.description({ params: { id: '10' }, body: {} })).toBe('Removed 0 books from collection #10');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,12 @@ const PAGE_WINDOW_INVALID_MESSAGE = `pagination window is too deep; page * size
|
||||
const SIZE_QUERY_INVALID_MESSAGE = 'size must be between 1 and 100';
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function describeSelection(action: 'Added' | 'Removed', body: { bookIds?: number[]; query?: unknown }, collectionId: string | undefined): string {
|
||||
if (body.query) return `${action} all matching books ${action === 'Added' ? 'to' : 'from'} collection #${collectionId}`;
|
||||
const count = body.bookIds?.length ?? 0;
|
||||
return `${action} ${count} book${count !== 1 ? 's' : ''} ${action === 'Added' ? 'to' : 'from'} collection #${collectionId}`;
|
||||
}
|
||||
|
||||
@Controller('collections')
|
||||
export class CollectionController {
|
||||
constructor(private readonly collectionService: CollectionService) {}
|
||||
@@ -75,6 +81,11 @@ export class CollectionController {
|
||||
return this.collectionService.findAll(user, bookIds);
|
||||
}
|
||||
|
||||
@Post('membership')
|
||||
findAllWithMembership(@Body() dto: CollectionBooksDto, @CurrentUser() user: RequestUser) {
|
||||
return this.collectionService.findAllWithSelectionMembership(dto, user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.collectionService.findOne(id, user);
|
||||
@@ -126,8 +137,7 @@ export class CollectionController {
|
||||
resource: AuditResource.Collection,
|
||||
getResourceId: (req) => parseInt(req.params['id'] as string, 10),
|
||||
description: (req) => {
|
||||
const count = (req.body as { bookIds?: number[] })?.bookIds?.length ?? 0;
|
||||
return `Added ${count} book${count !== 1 ? 's' : ''} to collection #${req.params['id']}`;
|
||||
return describeSelection('Added', req.body as { bookIds?: number[]; query?: unknown }, req.params['id'] as string | undefined);
|
||||
},
|
||||
})
|
||||
addBooks(@Param('id', ParseIntPipe) id: number, @Body() dto: CollectionBooksDto, @CurrentUser() user: RequestUser) {
|
||||
@@ -140,8 +150,7 @@ export class CollectionController {
|
||||
resource: AuditResource.Collection,
|
||||
getResourceId: (req) => parseInt(req.params['id'] as string, 10),
|
||||
description: (req) => {
|
||||
const count = (req.body as { bookIds?: number[] })?.bookIds?.length ?? 0;
|
||||
return `Removed ${count} book${count !== 1 ? 's' : ''} from collection #${req.params['id']}`;
|
||||
return describeSelection('Removed', req.body as { bookIds?: number[]; query?: unknown }, req.params['id'] as string | undefined);
|
||||
},
|
||||
})
|
||||
removeBooks(@Param('id', ParseIntPipe) id: number, @Body() dto: CollectionBooksDto, @CurrentUser() user: RequestUser) {
|
||||
|
||||
@@ -56,11 +56,6 @@ function makeService() {
|
||||
buildMembershipWhere: vi.fn(),
|
||||
};
|
||||
|
||||
const bookReadService = {
|
||||
findLibraryIdsByBookIds: vi.fn(),
|
||||
findCardsByBookIds: vi.fn(),
|
||||
};
|
||||
|
||||
const libraryService = {
|
||||
verifyUserAccess: vi.fn(),
|
||||
findAccessibleLibraryIds: vi.fn(),
|
||||
@@ -71,6 +66,7 @@ function makeService() {
|
||||
};
|
||||
|
||||
const bookService = {
|
||||
resolveSelectionToIds: vi.fn(),
|
||||
executeBooksQuery: vi.fn(),
|
||||
executeJumpBucketsQuery: vi.fn(),
|
||||
};
|
||||
@@ -81,13 +77,12 @@ function makeService() {
|
||||
|
||||
const service = new CollectionService(
|
||||
collectionRepo as never,
|
||||
bookReadService as never,
|
||||
libraryService as never,
|
||||
queryBuilder as never,
|
||||
bookService as never,
|
||||
achievementEvents as never,
|
||||
);
|
||||
return { service, collectionRepo, bookReadService, libraryService, queryBuilder, bookService, achievementEvents };
|
||||
return { service, collectionRepo, libraryService, queryBuilder, bookService, achievementEvents };
|
||||
}
|
||||
|
||||
describe('CollectionService', () => {
|
||||
@@ -129,6 +124,31 @@ describe('CollectionService', () => {
|
||||
expect(collectionRepo.findAllForUser).toHaveBeenCalledWith(user.id);
|
||||
expect(collectionRepo.findAllForUserWithMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves selection payloads before loading membership counts', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
const user = makeUser();
|
||||
const selection = { query: { libraryId: 5, q: 'dune' } };
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([10, 11, 10]);
|
||||
collectionRepo.findAllForUserWithMembership.mockResolvedValue([makeCollection({ memberCount: 2 })]);
|
||||
|
||||
const result = await service.findAllWithSelectionMembership(selection, user);
|
||||
|
||||
expect(bookService.resolveSelectionToIds).toHaveBeenCalledWith(selection, user);
|
||||
expect(collectionRepo.findAllForUserWithMembership).toHaveBeenCalledWith(user.id, [10, 11]);
|
||||
expect(result[0]).toEqual(expect.objectContaining({ memberCount: 2 }));
|
||||
});
|
||||
|
||||
it('uses the plain collection list when a selection resolves to zero books', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([]);
|
||||
collectionRepo.findAllForUser.mockResolvedValue([makeCollection()]);
|
||||
|
||||
await service.findAllWithSelectionMembership({ query: { libraryId: 5 } }, makeUser());
|
||||
|
||||
expect(collectionRepo.findAllForUser).toHaveBeenCalledWith(1);
|
||||
expect(collectionRepo.findAllForUserWithMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
@@ -281,52 +301,55 @@ describe('CollectionService', () => {
|
||||
});
|
||||
|
||||
describe('addBooks', () => {
|
||||
it('verifies collection ownership and library access before adding books', async () => {
|
||||
const { service, collectionRepo, bookReadService, libraryService } = makeService();
|
||||
it('verifies collection ownership and resolves the selection before adding books', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 2 })]);
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 7, libraryId: 100 },
|
||||
{ id: 8, libraryId: 101 },
|
||||
]);
|
||||
libraryService.verifyUserAccess.mockResolvedValue(undefined);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([7, 8]);
|
||||
collectionRepo.addBooks.mockResolvedValue([]);
|
||||
const selection = { bookIds: [7, 8] };
|
||||
|
||||
const result = await service.addBooks(10, { bookIds: [7, 8] }, makeUser());
|
||||
const user = makeUser();
|
||||
const result = await service.addBooks(10, selection, user);
|
||||
|
||||
expect(bookReadService.findLibraryIdsByBookIds).toHaveBeenCalledWith([7, 8]);
|
||||
expect(libraryService.verifyUserAccess).toHaveBeenCalledTimes(2);
|
||||
expect(bookService.resolveSelectionToIds).toHaveBeenCalledWith(selection, user);
|
||||
expect(collectionRepo.addBooks).toHaveBeenCalledWith(10, [7, 8]);
|
||||
expect(result).toEqual(expect.objectContaining({ bookCount: 2 }));
|
||||
});
|
||||
|
||||
it('deduplicates repeated book ids for access checks', async () => {
|
||||
const { service, collectionRepo, bookReadService, libraryService } = makeService();
|
||||
it('deduplicates resolved book ids before writing membership rows', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 1 })]);
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([{ id: 7, libraryId: 100 }]);
|
||||
libraryService.verifyUserAccess.mockResolvedValue(undefined);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([7, 7]);
|
||||
|
||||
await service.addBooks(10, { bookIds: [7, 7] }, makeUser());
|
||||
|
||||
expect(bookReadService.findLibraryIdsByBookIds).toHaveBeenCalledWith([7]);
|
||||
expect(libraryService.verifyUserAccess).toHaveBeenCalledTimes(1);
|
||||
expect(collectionRepo.addBooks).toHaveBeenCalledWith(10, [7]);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when at least one book id is missing', async () => {
|
||||
const { service, collectionRepo, bookReadService } = makeService();
|
||||
it('does not write membership rows when the selection resolves to zero books', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 0 })]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([]);
|
||||
|
||||
const result = await service.addBooks(10, { query: { libraryId: 5 } }, makeUser());
|
||||
|
||||
expect(collectionRepo.addBooks).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(expect.objectContaining({ bookCount: 0 }));
|
||||
});
|
||||
|
||||
it('propagates selection resolution errors and does not write membership rows', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValue([makeCollection()]);
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([{ id: 7, libraryId: 100 }]);
|
||||
bookService.resolveSelectionToIds.mockRejectedValue(new NotFoundException('One or more books were not found'));
|
||||
|
||||
await expect(service.addBooks(10, { bookIds: [7, 8] }, makeUser())).rejects.toThrow(NotFoundException);
|
||||
expect(collectionRepo.addBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips per-library access lookups for superusers', async () => {
|
||||
const { service, collectionRepo, bookReadService, libraryService } = makeService();
|
||||
const { service, collectionRepo, bookService, libraryService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection({ userId: 88 })]).mockResolvedValueOnce([makeCollection({ bookCount: 2 })]);
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 7, libraryId: 100 },
|
||||
{ id: 8, libraryId: 101 },
|
||||
]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([7, 8]);
|
||||
|
||||
await service.addBooks(10, { bookIds: [7, 8] }, makeUser({ id: 42, isSuperuser: true }));
|
||||
|
||||
@@ -334,15 +357,26 @@ describe('CollectionService', () => {
|
||||
expect(collectionRepo.addBooks).toHaveBeenCalledWith(10, [7, 8]);
|
||||
});
|
||||
|
||||
it('propagates library access errors and does not write membership rows', async () => {
|
||||
const { service, collectionRepo, bookReadService, libraryService } = makeService();
|
||||
it('propagates selection access errors and does not write membership rows', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValue([makeCollection()]);
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([{ id: 7, libraryId: 100 }]);
|
||||
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access to this library'));
|
||||
bookService.resolveSelectionToIds.mockRejectedValue(new ForbiddenException('No access to this library'));
|
||||
|
||||
await expect(service.addBooks(10, { bookIds: [7] }, makeUser())).rejects.toThrow(ForbiddenException);
|
||||
expect(collectionRepo.addBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves query selections before adding all matching books', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
const selection = { query: { libraryId: 5, q: 'space opera' } };
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 3 })]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([10, 11, 12]);
|
||||
|
||||
await service.addBooks(10, selection, makeUser());
|
||||
|
||||
expect(bookService.resolveSelectionToIds).toHaveBeenCalledWith(selection, expect.objectContaining({ id: 1 }));
|
||||
expect(collectionRepo.addBooks).toHaveBeenCalledWith(10, [10, 11, 12]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBooks', () => {
|
||||
@@ -475,40 +509,67 @@ describe('CollectionService', () => {
|
||||
|
||||
describe('removeBooks', () => {
|
||||
it('updates collection membership and returns hydrated collection', async () => {
|
||||
const { service, collectionRepo } = makeService();
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 1 })]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([7]);
|
||||
collectionRepo.removeBooks.mockResolvedValue([{ collectionId: 10, bookId: 7 }]);
|
||||
|
||||
const result = await service.removeBooks(10, { bookIds: [7] }, makeUser());
|
||||
|
||||
expect(bookService.resolveSelectionToIds).toHaveBeenCalledWith({ bookIds: [7] }, expect.objectContaining({ id: 1 }));
|
||||
expect(collectionRepo.removeBooks).toHaveBeenCalledWith(10, [7]);
|
||||
expect(result).toEqual(expect.objectContaining({ bookCount: 1 }));
|
||||
});
|
||||
|
||||
it('skips book access checks while removing books from a collection', async () => {
|
||||
const { service, collectionRepo, bookReadService, libraryService } = makeService();
|
||||
it('deduplicates resolved ids before removing membership rows', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 0 })]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([7, 7]);
|
||||
collectionRepo.removeBooks.mockResolvedValue([{ collectionId: 10, bookId: 7 }]);
|
||||
|
||||
await service.removeBooks(10, { bookIds: [7] }, makeUser());
|
||||
await service.removeBooks(10, { bookIds: [7, 7] }, makeUser());
|
||||
|
||||
expect(bookReadService.findLibraryIdsByBookIds).not.toHaveBeenCalled();
|
||||
expect(libraryService.verifyUserAccess).not.toHaveBeenCalled();
|
||||
expect(collectionRepo.removeBooks).toHaveBeenCalledWith(10, [7]);
|
||||
});
|
||||
|
||||
it('resolves query selections before removing all matching books', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
const selection = { query: { libraryId: 5, q: 'finished' } };
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 0 })]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([10, 11]);
|
||||
|
||||
await service.removeBooks(10, selection, makeUser());
|
||||
|
||||
expect(bookService.resolveSelectionToIds).toHaveBeenCalledWith(selection, expect.objectContaining({ id: 1 }));
|
||||
expect(collectionRepo.removeBooks).toHaveBeenCalledWith(10, [10, 11]);
|
||||
});
|
||||
|
||||
it('does not write membership rows when a remove selection resolves to zero books', async () => {
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 0 })]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([]);
|
||||
|
||||
const result = await service.removeBooks(10, { query: { libraryId: 5 } }, makeUser());
|
||||
|
||||
expect(collectionRepo.removeBooks).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(expect.objectContaining({ bookCount: 0 }));
|
||||
});
|
||||
|
||||
it('rejects removeBooks calls from non-owners', async () => {
|
||||
const { service, collectionRepo } = makeService();
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValue([makeCollection({ userId: 88 })]);
|
||||
|
||||
await expect(service.removeBooks(10, { bookIds: [7] }, makeUser({ id: 1 }))).rejects.toThrow(ForbiddenException);
|
||||
expect(bookService.resolveSelectionToIds).not.toHaveBeenCalled();
|
||||
expect(collectionRepo.removeBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows superusers to remove books from collections they do not own', async () => {
|
||||
const { service, collectionRepo } = makeService();
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById
|
||||
.mockResolvedValueOnce([makeCollection({ userId: 88 })])
|
||||
.mockResolvedValueOnce([makeCollection({ userId: 88, bookCount: 0 })]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([7]);
|
||||
collectionRepo.removeBooks.mockResolvedValue([{ collectionId: 10, bookId: 7 }]);
|
||||
|
||||
const result = await service.removeBooks(10, { bookIds: [7] }, makeUser({ id: 1, isSuperuser: true }));
|
||||
@@ -518,16 +579,18 @@ describe('CollectionService', () => {
|
||||
});
|
||||
|
||||
it('throws NotFoundException when removing books from a missing collection', async () => {
|
||||
const { service, collectionRepo } = makeService();
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValue([]);
|
||||
|
||||
await expect(service.removeBooks(10, { bookIds: [7] }, makeUser())).rejects.toThrow(NotFoundException);
|
||||
expect(bookService.resolveSelectionToIds).not.toHaveBeenCalled();
|
||||
expect(collectionRepo.removeBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows repository errors while removing books', async () => {
|
||||
const { service, collectionRepo } = makeService();
|
||||
const { service, collectionRepo, bookService } = makeService();
|
||||
collectionRepo.findById.mockResolvedValue([makeCollection()]);
|
||||
bookService.resolveSelectionToIds.mockResolvedValue([7]);
|
||||
collectionRepo.removeBooks.mockRejectedValue(new Error('remove failed'));
|
||||
|
||||
await expect(service.removeBooks(10, { bookIds: [7] }, makeUser())).rejects.toThrow('remove failed');
|
||||
|
||||
@@ -7,7 +7,6 @@ import { resolveTimeZone } from '../../common/utils/timezone.utils';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { BookQueryBuilder } from '../book/book-query-builder.service';
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_COLLECTION_CREATED } from '../achievement/achievement-events.service';
|
||||
import { CollectionBooksDto } from './dto/collection-books.dto';
|
||||
@@ -18,7 +17,6 @@ import { CollectionRepository } from './collection.repository';
|
||||
|
||||
const COLLECTION_NOT_FOUND_MESSAGE = 'Collection not found';
|
||||
const COLLECTION_ACCESS_DENIED_MESSAGE = 'No access to this collection';
|
||||
const BOOKS_NOT_FOUND_MESSAGE = 'One or more books were not found';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
@@ -43,7 +41,6 @@ export class CollectionService {
|
||||
|
||||
constructor(
|
||||
private readonly collectionRepo: CollectionRepository,
|
||||
private readonly bookReadService: BookReadService,
|
||||
private readonly libraryService: LibraryService,
|
||||
private readonly queryBuilder: BookQueryBuilder,
|
||||
private readonly bookService: BookService,
|
||||
@@ -63,27 +60,25 @@ export class CollectionService {
|
||||
return collection;
|
||||
}
|
||||
|
||||
private async assertBookAccess(bookIds: number[], user: RequestUser): Promise<void> {
|
||||
const uniqueBookIds = [...new Set(bookIds)];
|
||||
const rows = await this.bookReadService.findLibraryIdsByBookIds(uniqueBookIds);
|
||||
if (rows.length !== uniqueBookIds.length) {
|
||||
throw new NotFoundException(BOOKS_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
|
||||
if (user.isSuperuser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueLibraryIds = [...new Set(rows.map((row) => row.libraryId))];
|
||||
await Promise.all(uniqueLibraryIds.map((libraryId) => this.libraryService.verifyUserAccess(user.id, libraryId, false)));
|
||||
}
|
||||
|
||||
private buildErrorLogFields(error: unknown): { errorClass: string; errorMessage: string } {
|
||||
const errorClass = error instanceof Error ? error.name : 'Error';
|
||||
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
|
||||
return { errorClass, errorMessage };
|
||||
}
|
||||
|
||||
private getSelectionMode(dto: CollectionBooksDto): 'ids' | 'query' {
|
||||
return dto.query ? 'query' : 'ids';
|
||||
}
|
||||
|
||||
private getRequestedCount(dto: CollectionBooksDto): number {
|
||||
return dto.bookIds?.length ?? 0;
|
||||
}
|
||||
|
||||
private async resolveSelectionBookIds(dto: CollectionBooksDto, user: RequestUser): Promise<number[]> {
|
||||
const ids = await this.bookService.resolveSelectionToIds(dto, user);
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
findAll(user: RequestUser, bookIds?: number[]) {
|
||||
if (bookIds && bookIds.length > 0) {
|
||||
return this.collectionRepo.findAllForUserWithMembership(user.id, bookIds);
|
||||
@@ -91,6 +86,11 @@ export class CollectionService {
|
||||
return this.collectionRepo.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
async findAllWithSelectionMembership(dto: CollectionBooksDto, user: RequestUser) {
|
||||
const bookIds = await this.resolveSelectionBookIds(dto, user);
|
||||
return this.findAll(user, bookIds);
|
||||
}
|
||||
|
||||
async findOne(id: number, user: RequestUser) {
|
||||
return this.findCollectionForUserOrThrow(id, user);
|
||||
}
|
||||
@@ -173,15 +173,17 @@ export class CollectionService {
|
||||
async addBooks(id: number, dto: CollectionBooksDto, user: RequestUser) {
|
||||
const event = 'collection.add_books';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] collectionId=${id} userId=${user.id} bookCount=${dto.bookIds.length} - add books started`);
|
||||
this.logger.log(
|
||||
`[${event}] [start] collectionId=${id} userId=${user.id} selectionMode=${this.getSelectionMode(dto)} requestedCount=${this.getRequestedCount(dto)} - add books started`,
|
||||
);
|
||||
try {
|
||||
await this.findCollectionForUserOrThrow(id, user);
|
||||
await this.assertBookAccess(dto.bookIds, user);
|
||||
await this.collectionRepo.addBooks(id, dto.bookIds);
|
||||
const bookIds = await this.resolveSelectionBookIds(dto, user);
|
||||
if (bookIds.length > 0) {
|
||||
await this.collectionRepo.addBooks(id, bookIds);
|
||||
}
|
||||
const [updated] = await this.collectionRepo.findById(id);
|
||||
this.logger.log(
|
||||
`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} bookCount=${dto.bookIds.length} - add books completed`,
|
||||
);
|
||||
this.logger.log(`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} bookCount=${bookIds.length} - add books completed`);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const { errorClass, errorMessage } = this.buildErrorLogFields(error);
|
||||
@@ -195,13 +197,18 @@ export class CollectionService {
|
||||
async removeBooks(id: number, dto: CollectionBooksDto, user: RequestUser) {
|
||||
const event = 'collection.remove_books';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] collectionId=${id} userId=${user.id} bookCount=${dto.bookIds.length} - remove books started`);
|
||||
this.logger.log(
|
||||
`[${event}] [start] collectionId=${id} userId=${user.id} selectionMode=${this.getSelectionMode(dto)} requestedCount=${this.getRequestedCount(dto)} - remove books started`,
|
||||
);
|
||||
try {
|
||||
await this.findCollectionForUserOrThrow(id, user);
|
||||
await this.collectionRepo.removeBooks(id, dto.bookIds);
|
||||
const bookIds = await this.resolveSelectionBookIds(dto, user);
|
||||
if (bookIds.length > 0) {
|
||||
await this.collectionRepo.removeBooks(id, bookIds);
|
||||
}
|
||||
const [updated] = await this.collectionRepo.findById(id);
|
||||
this.logger.log(
|
||||
`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} bookCount=${dto.bookIds.length} - remove books completed`,
|
||||
`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} bookCount=${bookIds.length} - remove books completed`,
|
||||
);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { ArrayMinSize, IsArray, IsInt, Min } from 'class-validator';
|
||||
import { BulkSelectionDto } from '../../../common/dto/bulk-selection.dto';
|
||||
|
||||
export class CollectionBooksDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
bookIds: number[];
|
||||
}
|
||||
export class CollectionBooksDto extends BulkSelectionDto {}
|
||||
|
||||
@@ -134,7 +134,9 @@ describe('Email DTO validation', () => {
|
||||
expect((await errorsFor(SendBookDto, { bookIds: [1], recipientIds: [2], groupIds: [3], providerId: 4, templateId: 5, fileId: 6 })).length).toBe(
|
||||
0,
|
||||
);
|
||||
expect((await errorsFor(SendBookDto, { query: { libraryId: 5 }, recipientIds: [2] })).length).toBe(0);
|
||||
expect((await errorsFor(SendBookDto, { bookIds: [] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(SendBookDto, { bookIds: [0] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(SendBookDto, { bookIds: [1], recipientIds: ['x'] })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { ArrayMinSize, IsArray, IsInt, IsOptional } from 'class-validator';
|
||||
import { IsArray, IsInt, IsOptional } from 'class-validator';
|
||||
|
||||
export class SendBookDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
bookIds: number[];
|
||||
import { BulkSelectionDto } from '../../../common/dto/bulk-selection.dto';
|
||||
|
||||
export class SendBookDto extends BulkSelectionDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
|
||||
@@ -12,6 +12,7 @@ import { EmailSendLogService } from './email-send-log.service';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { BookService } from '../book/book.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { SendBookDto } from './dto/send-book.dto';
|
||||
import * as fs from 'fs';
|
||||
@@ -29,6 +30,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
let sendLogService: EmailSendLogService;
|
||||
let transportService: EmailTransportService;
|
||||
let bookAccessService: EmailBookAccessService;
|
||||
let bookService: BookService;
|
||||
let templateService: EmailTemplateService;
|
||||
|
||||
const mockUser: RequestUser = {
|
||||
@@ -71,6 +73,12 @@ describe('EmailSendOrchestrator', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailSendOrchestrator,
|
||||
{
|
||||
provide: BookService,
|
||||
useValue: {
|
||||
resolveSelectionToIds: vi.fn().mockImplementation((dto: SendBookDto) => Promise.resolve(dto.bookIds ?? [])),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EmailProviderResolver,
|
||||
useValue: { resolve: vi.fn().mockResolvedValue(mockProvider) },
|
||||
@@ -137,6 +145,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
sendLogService = module.get<EmailSendLogService>(EmailSendLogService);
|
||||
transportService = module.get<EmailTransportService>(EmailTransportService);
|
||||
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
|
||||
bookService = module.get<BookService>(BookService);
|
||||
templateService = module.get<EmailTemplateService>(EmailTemplateService);
|
||||
|
||||
(fs.createReadStream as vi.Mock).mockReturnValue('mock-stream');
|
||||
@@ -154,6 +163,20 @@ describe('EmailSendOrchestrator', () => {
|
||||
expect(recipientService.getOwnedByIds).toHaveBeenCalledWith([10], mockUser);
|
||||
});
|
||||
|
||||
it('should resolve query selections and queue every matching book', async () => {
|
||||
const dto: SendBookDto = { query: { libraryId: 5, q: 'dune' }, recipientIds: [10], providerId: 300 };
|
||||
(bookService.resolveSelectionToIds as vi.Mock).mockResolvedValue([1, 2, 2]);
|
||||
|
||||
const result = await orchestrator.send(dto, mockUser);
|
||||
|
||||
expect(result.queued).toBe(2);
|
||||
expect(bookService.resolveSelectionToIds).toHaveBeenCalledWith(dto, mockUser);
|
||||
expect(bookAccessService.assertUserCanAccessBooks).toHaveBeenCalledWith([1, 2], mockUser);
|
||||
expect(sendLogService.create).toHaveBeenCalledTimes(2);
|
||||
expect(sendLogService.create).toHaveBeenNthCalledWith(1, expect.objectContaining({ bookId: 1 }));
|
||||
expect(sendLogService.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ bookId: 2 }));
|
||||
});
|
||||
|
||||
it('should expand groups and queue emails', async () => {
|
||||
const dto: SendBookDto = { bookIds: [1], groupIds: [5] };
|
||||
const result = await orchestrator.send(dto, mockUser);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NotificationType } from '@bookorbit/types';
|
||||
import type { BookFile } from '../../db/schema';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import { EmailFileSelector } from './email-file-selector';
|
||||
import { EmailPreferencesService } from './email-preferences.service';
|
||||
@@ -40,6 +41,7 @@ export class EmailSendOrchestrator {
|
||||
private readonly logger = new Logger(EmailSendOrchestrator.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookService: BookService,
|
||||
private readonly bookAccessService: EmailBookAccessService,
|
||||
private readonly providerResolver: EmailProviderResolver,
|
||||
private readonly fileSelector: EmailFileSelector,
|
||||
@@ -57,11 +59,12 @@ export class EmailSendOrchestrator {
|
||||
const startedAt = Date.now();
|
||||
const requestedRecipientCount = (dto.recipientIds?.length ?? 0) + (dto.groupIds?.length ?? 0);
|
||||
this.logger.log(
|
||||
`[${EMAIL_SEND_EVENT}] [start] userId=${user.id} bookCount=${dto.bookIds.length} requestedRecipientCount=${requestedRecipientCount} - send enqueue started`,
|
||||
`[${EMAIL_SEND_EVENT}] [start] userId=${user.id} selectionMode=${dto.query ? 'query' : 'ids'} requestedCount=${dto.bookIds?.length ?? 0} requestedRecipientCount=${requestedRecipientCount} - send enqueue started`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.bookAccessService.assertUserCanAccessBooks(dto.bookIds, user);
|
||||
const bookIds = [...new Set(await this.bookService.resolveSelectionToIds(dto, user))];
|
||||
await this.bookAccessService.assertUserCanAccessBooks(bookIds, user);
|
||||
|
||||
const preferences = await this.preferencesService.getForUser(user.id);
|
||||
const tasks = await this.buildTasks(dto, user, preferences?.defaultTemplateId ?? null);
|
||||
@@ -71,7 +74,7 @@ export class EmailSendOrchestrator {
|
||||
|
||||
let queued = 0;
|
||||
for (const task of tasks) {
|
||||
for (const bookId of dto.bookIds) {
|
||||
for (const bookId of bookIds) {
|
||||
await this.enqueueOne({ ...task, bookId, userId: user.id }, resolved, dto.fileId ?? null, user);
|
||||
queued++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user