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:
neon
2026-06-25 01:13:45 +00:00
parent 8f79e92053
commit 9bfd134092
40 changed files with 538 additions and 234 deletions
@@ -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"
/>
+10 -3
View File
@@ -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">
+10 -2
View File
@@ -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" />
+9 -2
View File
@@ -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" />
+10
View File
@@ -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 };
+1
View File
@@ -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;
}
+29 -5
View File
@@ -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 });
+6 -2
View File
@@ -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++;
}