feat(scanner): rebuild the Book Dock page as a uniform-row triage list

Replace the nine-column table with one list of uniform rows that reflows through a container
query, so a single component serves both desktop and phone. Surface the match score, applied
state and write path that the old row buried in tertiary text.

Back it with a needsReview filter and attention sort over existing indexed columns, threaded
through every bulk selection path so select-all cannot target the wrong rows. Keep confidence
advisory: it gates automatic finalization only.

BookDockSummary gains three additive counts; ../bookorbit-ios mirrors these types by hand.
This commit is contained in:
neon
2026-08-12 00:39:08 +00:00
parent f199e46d1c
commit 0087164c2e
25 changed files with 1901 additions and 1204 deletions
@@ -181,6 +181,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
overrides?: Array<{ fileId: number } & FinalizeOverrideEntry>,
status?: string,
search?: string,
needsReview?: boolean,
): Promise<BookDockFinalizeResult> {
const ids = selectAll ? [] : dedupeIds(fileIds ?? []);
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
@@ -198,6 +199,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
});
@@ -331,25 +333,36 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
overrides?: Array<{ fileId: number } & FinalizeOverrideEntry>,
status?: string,
search?: string,
needsReview?: boolean,
): Promise<BookDockFinalizePreviewResult> {
const summary = createFinalizePreviewSummary();
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
await this.processFinalizeSelection(userId, canManageAll, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
for (const candidate of prepared.analyses) {
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
addFinalizePreviewAnalysis(summary, analysis);
}
for (const fileId of missingIds) {
addFinalizePreviewItem(summary, {
fileId,
fileName: `book-dock-file-${fileId}`,
status: 'error',
message: 'Book Dock file not found',
});
}
});
await this.processFinalizeSelection(
userId,
canManageAll,
fileIds,
selectAll,
excludedIds,
status,
search,
needsReview,
async (rows, missingIds) => {
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
for (const candidate of prepared.analyses) {
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
addFinalizePreviewAnalysis(summary, analysis);
}
for (const fileId of missingIds) {
addFinalizePreviewItem(summary, {
fileId,
fileName: `book-dock-file-${fileId}`,
status: 'error',
message: 'Book Dock file not found',
});
}
},
);
return summary;
}
@@ -366,6 +379,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
overrides?: Array<{ fileId: number } & FinalizeOverrideEntry>,
status?: string,
search?: string,
needsReview?: boolean,
): Promise<BookDockDiscardDuplicatesResult> {
const startedAt = Date.now();
this.logger.log(`[book_dock.discard_duplicates] [start] userId=${userId} selectAll=${selectAll === true} - duplicate discard started`);
@@ -376,25 +390,35 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const discardedFileIds: number[] = [];
try {
await this.processFinalizeSelection(userId, canManageAll, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
total += rows.length + missingIds.length;
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
const duplicateRows: BookDockFileRow[] = [];
await this.processFinalizeSelection(
userId,
canManageAll,
fileIds,
selectAll,
excludedIds,
status,
search,
needsReview,
async (rows, missingIds) => {
total += rows.length + missingIds.length;
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
const duplicateRows: BookDockFileRow[] = [];
for (const candidate of prepared.analyses) {
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
if (analysis.status === 'duplicate') duplicateRows.push(candidate.row);
}
for (const candidate of prepared.analyses) {
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
if (analysis.status === 'duplicate') duplicateRows.push(candidate.row);
}
if (duplicateRows.length === 0) return;
if (duplicateRows.length === 0) return;
for (const row of duplicateRows) {
await this.cleanupDiscardedBookDockFile(row);
discardedFileIds.push(row.id);
}
await this.repo.deleteByIds(duplicateRows.map((row) => row.id));
discarded += duplicateRows.length;
});
for (const row of duplicateRows) {
await this.cleanupDiscardedBookDockFile(row);
discardedFileIds.push(row.id);
}
await this.repo.deleteByIds(duplicateRows.map((row) => row.id));
discarded += duplicateRows.length;
},
);
this.emitChange();
const result = { total, discarded, skipped: total - discarded, discardedFileIds: selectAll ? [] : discardedFileIds };
@@ -558,6 +582,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
excludedIds: number[] | undefined,
status: string | undefined,
search: string | undefined,
needsReview: boolean | undefined,
processBatch: (rows: BookDockFileRow[], missingIds: number[]) => Promise<void>,
): Promise<void> {
if (selectAll) {
@@ -569,6 +594,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
});
@@ -690,8 +716,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
canManageAll: boolean | undefined,
status?: string,
search?: string,
needsReview?: boolean,
): Promise<{ fileId: number; fileName: string; newName: string }[]> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, canManageAll) : (fileIds ?? []);
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, canManageAll, needsReview) : (fileIds ?? []);
if (!ids.length) return [];
const rows = await this.repo.findByIds(ids, userId, canManageAll);
@@ -182,11 +182,24 @@ describe('BookDockController', () => {
await controller.pause();
await controller.resume();
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false);
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined);
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined);
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined);
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, true, [1], false, [], 2, 3, [], undefined, undefined);
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false, undefined);
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined, undefined);
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined, undefined);
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(
1,
false,
false,
[10],
false,
[],
2,
3,
[],
undefined,
undefined,
undefined,
);
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, true, [1], false, [], 2, 3, [], undefined, undefined, undefined);
expect(watcherService.rescan).toHaveBeenCalled();
expect(service.pauseProcessing).toHaveBeenCalledTimes(1);
expect(service.resumeProcessing).toHaveBeenCalledTimes(1);
@@ -63,6 +63,7 @@ export class BookDockController {
listFiles(@CurrentUser() user: RequestUser, @Query() query: ListBookDockFilesDto) {
return this.service.listFiles({
status: query.status,
needsReview: query.needsReview,
page: query.page ?? 1,
limit: query.limit ?? 20,
sort: query.sort ?? 'createdAt',
@@ -143,17 +144,44 @@ export class BookDockController {
@Post('files/discard')
@HttpCode(HttpStatus.NO_CONTENT)
bulkDiscard(@CurrentUser() user: RequestUser, @Body() dto: BulkDiscardDto) {
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
return this.service.bulkDiscard(
dto.fileIds ?? [],
dto.selectAll,
dto.excludedIds,
dto.status,
dto.search,
user.id,
this.canManageAll(user),
dto.needsReview,
);
}
@Post('files/apply-fetched')
applyFetched(@CurrentUser() user: RequestUser, @Body() dto: BulkApplyFetchedDto) {
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
return this.service.bulkApplyFetched(
dto.fileIds ?? [],
dto.selectAll,
dto.excludedIds,
dto.status,
dto.search,
user.id,
this.canManageAll(user),
dto.needsReview,
);
}
@Post('files/retry-fetch')
retryFetch(@CurrentUser() user: RequestUser, @Body() dto: BulkRetryFetchDto) {
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
return this.service.bulkRetryFetch(
dto.fileIds,
dto.selectAll,
dto.excludedIds,
dto.status,
dto.search,
user.id,
this.canManageAll(user),
dto.needsReview,
);
}
@Post('files/set-target')
@@ -168,12 +196,22 @@ export class BookDockController {
dto.search,
user.id,
this.canManageAll(user),
dto.needsReview,
);
}
@Post('files/selection-summary')
selectionSummary(@CurrentUser() user: RequestUser, @Body() dto: SelectionSummaryDto) {
return this.service.selectionSummary(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
return this.service.selectionSummary(
dto.fileIds ?? [],
dto.selectAll,
dto.excludedIds,
dto.status,
dto.search,
user.id,
this.canManageAll(user),
dto.needsReview,
);
}
@Post('files/bulk-edit')
@@ -190,6 +228,7 @@ export class BookDockController {
dto.search,
user.id,
this.canManageAll(user),
dto.needsReview,
);
}
@@ -204,6 +243,7 @@ export class BookDockController {
this.canManageAll(user),
dto.status,
dto.search,
dto.needsReview,
);
}
@@ -223,6 +263,7 @@ export class BookDockController {
dto.overrides,
dto.status,
dto.search,
dto.needsReview,
);
}
@@ -242,6 +283,7 @@ export class BookDockController {
dto.overrides,
dto.status,
dto.search,
dto.needsReview,
);
}
@@ -271,6 +313,7 @@ export class BookDockController {
dto.overrides,
dto.status,
dto.search,
dto.needsReview,
);
}
@@ -7,7 +7,9 @@ vi.mock('drizzle-orm', () => ({
gt: vi.fn((left: unknown, right: unknown) => ({ op: 'gt', left, right })),
ilike: vi.fn((left: unknown, right: unknown) => ({ op: 'ilike', left, right })),
inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })),
isNotNull: vi.fn((value: unknown) => ({ op: 'isNotNull', value })),
isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })),
lt: vi.fn((left: unknown, right: unknown) => ({ op: 'lt', left, right })),
notInArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'notInArray', left, right })),
or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })),
sql: Object.assign(
@@ -115,18 +117,21 @@ describe('BookDockRepository', () => {
it('countsByStatus maps queue statuses and computes total', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.groupBy.mockResolvedValue([
{ status: 'pending', cnt: '3' },
{ status: 'ready', cnt: '5' },
{ status: 'error', cnt: '2' },
{ status: 'extracting', cnt: '1' },
{ status: 'fetching', cnt: '4' },
{ status: 'pending', cnt: '3', needsReview: '0', readyToFile: '0' },
{ status: 'ready', cnt: '5', needsReview: '2', readyToFile: '3' },
{ status: 'error', cnt: '2', needsReview: '0', readyToFile: '0' },
{ status: 'extracting', cnt: '1', needsReview: '0', readyToFile: '0' },
{ status: 'fetching', cnt: '4', needsReview: '0', readyToFile: '0' },
]);
const repo = new BookDockRepository(db as never);
await expect(repo.countsByStatus()).resolves.toEqual({
pending: 8,
working: 5,
ready: 5,
error: 2,
needsReview: 2,
readyToFile: 3,
total: 15,
});
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, count, desc, eq, gt, inArray, notInArray, sum, type SQL } from 'drizzle-orm';
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, notInArray, or, sql, sum, type SQL } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -17,8 +17,29 @@ const SORT_COLUMNS = {
fileSize: bookDockFiles.fileSize,
} as const;
/** Below this, a provider match is treated as a guess the user should confirm. */
export const NEEDS_REVIEW_CONFIDENCE_BELOW = 70;
/**
* A file is ready to file but still wants a human: either nothing decided where it
* goes, or the provider match is too weak to trust. Both are plain indexed columns,
* so this stays a WHERE clause rather than post-filtering a page in memory.
*/
/** Exactly what finalize accepts: ready, with both halves of a destination resolved. */
function readyToFileCondition(): SQL {
return and(eq(bookDockFiles.status, 'ready'), isNotNull(bookDockFiles.targetLibraryId), isNotNull(bookDockFiles.targetFolderId)) as SQL;
}
function needsReviewCondition(): SQL {
return and(
eq(bookDockFiles.status, 'ready'),
or(isNull(bookDockFiles.targetLibraryId), isNull(bookDockFiles.targetFolderId), lt(bookDockFiles.confidence, NEEDS_REVIEW_CONFIDENCE_BELOW)),
) as SQL;
}
export interface ListOptions {
status?: string;
needsReview?: boolean;
page: number;
limit: number;
sort: string;
@@ -33,6 +54,7 @@ export interface SelectionBatchOptions {
afterId?: number;
excludedIds?: number[];
status?: string;
needsReview?: boolean;
search?: string;
userId: number;
canManageAll: boolean;
@@ -43,20 +65,22 @@ export class BookDockRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findAll(opts: ListOptions): Promise<{ items: BookDockFileRow[]; total: number }> {
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.canManageAll);
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.canManageAll, opts.needsReview);
const where = conditions.length ? and(...conditions) : undefined;
const sortKey = opts.sort as keyof typeof SORT_COLUMNS;
const sortCol = SORT_COLUMNS[sortKey] ?? bookDockFiles.createdAt;
const orderFn = opts.order === 'asc' ? asc : desc;
const orderBy =
opts.sort === 'attention'
? [this.attentionRank(), desc(bookDockFiles.createdAt), desc(bookDockFiles.id)]
: [orderFn(SORT_COLUMNS[opts.sort as keyof typeof SORT_COLUMNS] ?? bookDockFiles.createdAt), orderFn(bookDockFiles.id)];
const [items, [{ total }]] = await Promise.all([
this.db
.select()
.from(bookDockFiles)
.where(where)
.orderBy(orderFn(sortCol), orderFn(bookDockFiles.id))
.orderBy(...orderBy)
.limit(opts.limit)
.offset((opts.page - 1) * opts.limit),
this.db.select({ total: count() }).from(bookDockFiles).where(where),
@@ -65,6 +89,20 @@ export class BookDockRepository {
return { items, total };
}
/**
* Decisions first, then failures, then settled files, then work still in flight -
* which the client also surfaces as a progress readout, so it never needs the top
* of the list.
*/
private attentionRank(): SQL {
return sql`case
when ${needsReviewCondition()} then 0
when ${bookDockFiles.status} = 'error' then 1
when ${bookDockFiles.status} = 'ready' then 2
when ${bookDockFiles.status} = 'pending' then 3
else 4 end`;
}
async findById(id: number): Promise<BookDockFileRow | undefined> {
const [row] = await this.db.select().from(bookDockFiles).where(eq(bookDockFiles.id, id)).limit(1);
return row;
@@ -98,8 +136,15 @@ export class BookDockRepository {
await this.db.delete(bookDockFiles).where(eq(bookDockFiles.absolutePath, path));
}
async findAllIds(excludedIds?: number[], status?: string, search?: string, userId?: number, canManageAll?: boolean): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search, userId, canManageAll ?? true);
async findAllIds(
excludedIds?: number[],
status?: string,
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search, userId, canManageAll ?? true, needsReview);
if (excludedIds?.length) conditions.push(notInArray(bookDockFiles.id, excludedIds));
const where = conditions.length ? and(...conditions) : undefined;
const rows = await this.db.select({ id: bookDockFiles.id }).from(bookDockFiles).where(where);
@@ -134,7 +179,7 @@ export class BookDockRepository {
}
async findSelectionBatch(options: SelectionBatchOptions): Promise<BookDockFileRow[]> {
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.canManageAll);
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.canManageAll, options.needsReview);
if (options.excludedIds?.length) conditions.push(notInArray(bookDockFiles.id, options.excludedIds));
if (options.afterId !== undefined) conditions.push(gt(bookDockFiles.id, options.afterId));
const where = conditions.length ? and(...conditions) : undefined;
@@ -151,23 +196,37 @@ export class BookDockRepository {
return updated.length;
}
async countsByStatus(userId?: number, canManageAll?: boolean): Promise<{ pending: number; ready: number; error: number; total: number }> {
async countsByStatus(
userId?: number,
canManageAll?: boolean,
): Promise<{ pending: number; working: number; ready: number; error: number; needsReview: number; readyToFile: number; total: number }> {
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, canManageAll ?? true) : undefined;
// A filtered aggregate keeps this to one round trip: only the 'ready' group can
// contribute, since needing review presupposes the file is otherwise done.
const rows = await this.db
.select({
status: bookDockFiles.status,
cnt: count(),
needsReview: sql<number>`count(*) filter (where ${needsReviewCondition()})`,
readyToFile: sql<number>`count(*) filter (where ${readyToFileCondition()})`,
})
.from(bookDockFiles)
.where(visibilityCondition)
.groupBy(bookDockFiles.status);
const result = { pending: 0, ready: 0, error: 0, total: 0 };
// `pending` keeps its existing meaning of everything not yet settled, so clients
// that already read it are unaffected; `working` is the in-flight subset of it.
const result = { pending: 0, working: 0, ready: 0, error: 0, needsReview: 0, readyToFile: 0, total: 0 };
for (const row of rows) {
const n = Number(row.cnt);
if (row.status === 'pending' || row.status === 'extracting' || row.status === 'fetching') result.pending += n;
else if (row.status === 'ready') result.ready = n;
if (row.status === 'pending' || row.status === 'extracting' || row.status === 'fetching') {
result.pending += n;
if (row.status !== 'pending') result.working += n;
} else if (row.status === 'ready') result.ready = n;
else if (row.status === 'error') result.error = n;
result.needsReview += Number(row.needsReview ?? 0);
result.readyToFile += Number(row.readyToFile ?? 0);
result.total += n;
}
return result;
@@ -206,13 +265,14 @@ export class BookDockRepository {
return eq(bookDockFiles.uploadedBy, userId);
}
private buildSelectionConditions(status?: string, search?: string, userId?: number, canManageAll?: boolean): SQL[] {
private buildSelectionConditions(status?: string, search?: string, userId?: number, canManageAll?: boolean, needsReview?: boolean): SQL[] {
const conditions: SQL[] = [];
if (status === 'pending') {
conditions.push(inArray(bookDockFiles.status, ['pending', 'extracting', 'fetching']));
} else if (status) {
conditions.push(eq(bookDockFiles.status, status));
}
if (needsReview) conditions.push(needsReviewCondition());
if (search) conditions.push(accentInsensitiveIlike(bookDockFiles.fileName, `%${search}%`));
if (userId !== undefined && !canManageAll) {
conditions.push(eq(bookDockFiles.uploadedBy, userId));
@@ -92,6 +92,7 @@ export class BookDockService {
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
): Promise<void> {
await this.processSelectionRows(
{
@@ -100,6 +101,7 @@ export class BookDockService {
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
},
@@ -123,6 +125,7 @@ export class BookDockService {
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
let updated = 0;
let failed = 0;
@@ -133,6 +136,7 @@ export class BookDockService {
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
},
@@ -173,6 +177,7 @@ export class BookDockService {
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
): Promise<{ total: number; applied: number; skipped: number; skippedEdited: number }> {
let applied = 0;
let skipped = 0;
@@ -184,6 +189,7 @@ export class BookDockService {
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
},
@@ -217,6 +223,7 @@ export class BookDockService {
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
): Promise<{ total: number; queued: number }> {
let queued = 0;
const total = await this.processSelectionRows(
@@ -226,6 +233,7 @@ export class BookDockService {
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
},
@@ -251,6 +259,7 @@ export class BookDockService {
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
await this.assertValidTarget(targetLibraryId, targetFolderId);
let updated = 0;
@@ -261,6 +270,7 @@ export class BookDockService {
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
},
@@ -284,6 +294,7 @@ export class BookDockService {
search?: string,
userId?: number,
canManageAll?: boolean,
needsReview?: boolean,
): Promise<{ total: number; withDestination: number; withoutDestination: number }> {
const destinationPairCounts = new Map<string, number>();
const folderIdSet = new Set<number>();
@@ -294,6 +305,7 @@ export class BookDockService {
excludedIds,
status,
search,
needsReview,
userId,
canManageAll,
},
@@ -460,6 +472,7 @@ export class BookDockService {
excludedIds?: number[];
status?: string;
search?: string;
needsReview?: boolean;
userId?: number;
canManageAll?: boolean;
},
@@ -478,6 +491,7 @@ export class BookDockService {
excludedIds: options.excludedIds,
status: options.status,
search: options.search,
needsReview: options.needsReview,
userId,
canManageAll,
});
+32
View File
@@ -187,6 +187,10 @@ export class FinalizeBookDockDto {
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsInt()
@@ -226,6 +230,10 @@ export class BulkDiscardDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
}
export class PreviewNamesDto {
@@ -251,6 +259,10 @@ export class PreviewNamesDto {
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsInt()
@@ -279,6 +291,10 @@ export class BulkRetryFetchDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
}
export class BulkApplyFetchedDto {
@@ -303,6 +319,10 @@ export class BulkApplyFetchedDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
}
export class BulkSetTargetDto {
@@ -328,6 +348,10 @@ export class BulkSetTargetDto {
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsInt()
@@ -361,6 +385,10 @@ export class SelectionSummaryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
}
export class BulkEditBookDockDto {
@@ -386,6 +414,10 @@ export class BulkEditBookDockDto {
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
needsReview?: boolean;
@ValidateNested()
@IsObject()
@Type(() => BookDockMetadataFieldsDto)
@@ -1,8 +1,8 @@
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { Transform } from 'class-transformer';
const VALID_STATUSES = ['pending', 'ready', 'error'] as const;
const VALID_SORT_FIELDS = ['createdAt', 'fileName', 'format', 'status', 'fileSize'] as const;
const VALID_SORT_FIELDS = ['createdAt', 'fileName', 'format', 'status', 'fileSize', 'attention'] as const;
const VALID_ORDERS = ['asc', 'desc'] as const;
export class ListBookDockFilesDto {
@@ -10,6 +10,11 @@ export class ListBookDockFilesDto {
@IsIn(VALID_STATUSES)
status?: string;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true' || value === '1')
@IsBoolean()
needsReview?: boolean;
@IsOptional()
@Transform(({ value }) => parseInt(value as string, 10))
@IsInt()