From 15cf1064589af43d8e1571d7a4680833f974da34 Mon Sep 17 00:00:00 2001 From: Yehor Makarenko Date: Mon, 21 Sep 2026 09:13:58 +0300 Subject: [PATCH 1/5] TSA-328: * resume returns document object with details --- .../modules/documents/document.controller.ts | 9 +- .../modules/documents/document.repository.ts | 57 +++++++++++++ .../src/modules/documents/document.service.ts | 82 +++++++++++-------- 3 files changed, 113 insertions(+), 35 deletions(-) diff --git a/apps/backend/src/modules/documents/document.controller.ts b/apps/backend/src/modules/documents/document.controller.ts index 40482bed..d62ed9e0 100644 --- a/apps/backend/src/modules/documents/document.controller.ts +++ b/apps/backend/src/modules/documents/document.controller.ts @@ -575,7 +575,7 @@ class DocumentController extends BaseController { * @swagger * /documents/{id}/resume: * post: - * description: Back to processing, and re-enqueue what still needs work + * description: Back to processing, and re-enqueue what still needs work. Returns current document DTO * security: * - bearerAuth: [] * parameters: @@ -598,9 +598,12 @@ class DocumentController extends BaseController { private async resume( options: DocumentIdHandlerOptions, ): Promise { - await this.documentService.resume(options.params.id, options.user.userId); + const document = await this.documentService.resume( + options.params.id, + options.user.userId, + ); return { - payload: null, + payload: document, status: HTTPCode.OK, }; } diff --git a/apps/backend/src/modules/documents/document.repository.ts b/apps/backend/src/modules/documents/document.repository.ts index 05cf0e24..1b21b4ca 100644 --- a/apps/backend/src/modules/documents/document.repository.ts +++ b/apps/backend/src/modules/documents/document.repository.ts @@ -152,6 +152,63 @@ class DocumentRepository { return document ? DocumentEntity.initialize(document) : null; } + public async findByIdAndOwnerIdForUpdateWithDetails( + id: number, + ownerId: number, + trx: Transaction, + ): Promise { + const knex = this.documentModel.knex(); + + const document = await knex + .select([ + "dp.documentId as id", + "d.error_message as errorMessage", + "dp.title", + "dp.status", + "dp.pageCount", + "dp.cursorPageNo", + knex.raw("round(dp.budget_usd, 2)::text as ??", ["budgetUsd"]), + knex.raw("round(dp.spent_usd, 2)::text as ??", ["spentUsd"]), + knex.raw( + "coalesce(round(dp.spent_usd / nullif(dp.budget_usd, 0) * 100, 1), 0)::float8 as ??", + ["usedPct"], + ), + "pr.id as presetId", + "pr.name as presetName", + "pr.version as presetVersion", + "dp.pagesTotal", + "dp.pagesVerified", + "dp.pagesReadyToCheck", + "dp.pagesInWork", + "dp.pagesPending", + "dp.pagesFailed", + "dp.pagesBlank", + "dp.pagesSkipped", + "dp.verifiedPct", + "dp.closedPct", + ]) + .from(`${DatabaseTableName.DOCUMENT} as d`) + .innerJoin( + `${DatabaseTableName.DOCUMENT_PROGRESS} as dp`, + "dp.documentId", + "d.id", + ) + .innerJoin(`${DatabaseTableName.PRESET} as pr`, "pr.id", "d.presetId") + .where({ + "d.id": id, + "d.ownerId": ownerId, + }) + .transacting(trx) + .forUpdate("d") + .first(); + + if (!document) { + return null; + } + + return DocumentDetailsEntity.initialize(document); + } + public async findDraftsOlderThan(date: string): Promise { const documents = await this.documentModel .query() diff --git a/apps/backend/src/modules/documents/document.service.ts b/apps/backend/src/modules/documents/document.service.ts index 5147c714..6dfdb794 100644 --- a/apps/backend/src/modules/documents/document.service.ts +++ b/apps/backend/src/modules/documents/document.service.ts @@ -476,13 +476,6 @@ class DocumentService { }); } - private throwInvalidStatusToResumeError(): never { - throw new HTTPError({ - message: DocumentValidationMessage.INVALID_STATUS_TO_RESUME, - status: HTTPCode.CONFLICT, - }); - } - public async create({ fileBytes, fileName, @@ -840,39 +833,62 @@ class DocumentService { this.throwInvalidStatusToPauseError(); } } - public async resume(documentId: number, userId: number): Promise { - const pages = await DocumentModel.transaction(async (trx) => { - const document = - await this.documentRepository.findByIdAndOwnerIdForUpdate( + public async resume( + documentId: number, + userId: number, + ): Promise { + const { document, isPaused, pages } = await DocumentModel.transaction( + async (trx) => { + const document = + await this.documentRepository.findByIdAndOwnerIdForUpdateWithDetails( + documentId, + userId, + trx, + ); + + if (!document) { + this.throwDocumentNotFoundError(); + } + + const documentObject = document.toObject(); + + if (documentObject.status !== DocumentStatus.PAUSED) { + return { document: documentObject, isPaused: false, pages: [] }; + } + + await this.documentRepository.updateStatus( documentId, - userId, + DocumentStatus.PROCESSING, + trx, + ); + await this.pageRepository.updateFirstPendingPagesAsQueued( + documentId, + PAGES_TO_QUEUE, trx, ); - if (!document) { - this.throwDocumentNotFoundError(); - } - - if (document.toObject().status !== DocumentStatus.PAUSED) { - this.throwInvalidStatusToResumeError(); - } + const pages = await this.pageRepository.findQueuedPages( + documentId, + trx, + ); - await this.documentRepository.updateStatus( - documentId, - DocumentStatus.PROCESSING, - trx, - ); - await this.pageRepository.updateFirstPendingPagesAsQueued( - documentId, - PAGES_TO_QUEUE, - trx, - ); + return { + document: { + ...documentObject, + status: DocumentStatus.PROCESSING, + }, + isPaused: true, + pages, + }; + }, + ); - return await this.pageRepository.findQueuedPages(documentId, trx); - }); + if (!isPaused) { + return document; + } if (pages.length === EMPTY_COLLECTION_LENGTH) { - return; + return document; } try { @@ -887,6 +903,8 @@ class DocumentService { }); }), ); + + return document; } catch (error) { await this.documentRepository.updateOwnedStatusFrom({ currentStatus: DocumentStatus.PROCESSING, From 195fd74c7c6d1810b45f83448f5bf87e62a1d985 Mon Sep 17 00:00:00 2001 From: Yehor Makarenko Date: Mon, 21 Sep 2026 09:17:08 +0300 Subject: [PATCH 2/5] TSA-328: * fix status flicking --- .../src/modules/documents/slices/documents.slice.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/modules/documents/slices/documents.slice.ts b/apps/frontend/src/modules/documents/slices/documents.slice.ts index 4beb4f59..cf8a1597 100644 --- a/apps/frontend/src/modules/documents/slices/documents.slice.ts +++ b/apps/frontend/src/modules/documents/slices/documents.slice.ts @@ -90,7 +90,12 @@ const { actions, name, reducer } = createSlice({ }); builder.addCase(pollDocumentById.fulfilled, (state, action) => { if (state.document && state.document.id === action.payload.id) { - state.document = action.payload; + const nextStatus = + state.document.status === DocumentStatus.PAUSED + ? DocumentStatus.PAUSED + : action.payload.status; + + state.document = { ...action.payload, status: nextStatus }; } }); builder.addCase(remove.fulfilled, (state, action) => { From db36fc6156597dae46035ade3ce922fe8e1e6520 Mon Sep 17 00:00:00 2001 From: Yehor Makarenko Date: Mon, 21 Sep 2026 09:19:59 +0300 Subject: [PATCH 3/5] TSA-328: * set state.document to the action.payload after resume.fulfilled --- apps/frontend/src/modules/documents/documents-api.ts | 6 ++++-- apps/frontend/src/modules/documents/slices/actions.ts | 10 +++++++--- .../src/modules/documents/slices/documents.slice.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/modules/documents/documents-api.ts b/apps/frontend/src/modules/documents/documents-api.ts index 1df63851..bbf2798b 100644 --- a/apps/frontend/src/modules/documents/documents-api.ts +++ b/apps/frontend/src/modules/documents/documents-api.ts @@ -148,8 +148,8 @@ class DocumentApi extends BaseHTTPApi { ); } - public async resume(id: number): Promise { - await this.load( + public async resume(id: number): Promise { + const response = await this.load( this.getFullEndpoint(DocumentsApiPath.RESUME, { id: String(id), }), @@ -160,6 +160,8 @@ class DocumentApi extends BaseHTTPApi { payload: JSON.stringify({}), }, ); + + return await response.json(); } public async updateBudget( diff --git a/apps/frontend/src/modules/documents/slices/actions.ts b/apps/frontend/src/modules/documents/slices/actions.ts index bd96e54b..5c9dd2a9 100644 --- a/apps/frontend/src/modules/documents/slices/actions.ts +++ b/apps/frontend/src/modules/documents/slices/actions.ts @@ -143,14 +143,18 @@ const pollDocumentById = createAsyncThunk< { serializeError }, ); -const resume = createAsyncThunk( +const resume = createAsyncThunk< + DocumentGetByIdResponseDto, + number, + AsyncThunkConfig +>( `${sliceName}/resume`, async (id, { extra }) => { const { documentApi } = extra; - await documentApi.resume(id); + const document = await documentApi.resume(id); - return id; + return document; }, { serializeError }, ); diff --git a/apps/frontend/src/modules/documents/slices/documents.slice.ts b/apps/frontend/src/modules/documents/slices/documents.slice.ts index cf8a1597..34cd3751 100644 --- a/apps/frontend/src/modules/documents/slices/documents.slice.ts +++ b/apps/frontend/src/modules/documents/slices/documents.slice.ts @@ -118,7 +118,7 @@ const { actions, name, reducer } = createSlice({ state.pauseResumeDataStatuses[action.meta.arg] = DataStatus.FULFILLED; if (state.document && state.document.id === action.meta.arg) { - state.document.status = DocumentStatus.PROCESSING; + state.document = action.payload; } }); builder.addCase(pause.pending, (state, action) => { From 55811ccc1bda9f7f11e283a74435775e47d39933 Mon Sep 17 00:00:00 2001 From: Yehor Makarenko Date: Mon, 21 Sep 2026 10:17:42 +0300 Subject: [PATCH 4/5] TSA-328: * fix dispatch types --- .../libs/components/document-status-block.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/pages/documents/libs/components/document-status-block.tsx b/apps/frontend/src/pages/documents/libs/components/document-status-block.tsx index eba95267..8b9ca3d5 100644 --- a/apps/frontend/src/pages/documents/libs/components/document-status-block.tsx +++ b/apps/frontend/src/pages/documents/libs/components/document-status-block.tsx @@ -26,15 +26,15 @@ const DocumentStatusBlock: React.FC = ({ documentId, status }) => { const isPauseResumeLoading = pauseResumeDataStatus === DataStatus.PENDING; const handleToggleProcessing = useCallback(() => { - const action = isPaused ? documentActions.resume : documentActions.pause; - - void dispatch(action(documentId)) - .unwrap() - .then(() => { - if (isPaused) { - void dispatch(documentActions.startPolling(documentId)); - } - }); + const togglePromise = isPaused + ? dispatch(documentActions.resume(documentId)) + : dispatch(documentActions.pause(documentId)); + + void togglePromise.unwrap().then(() => { + if (isPaused) { + void dispatch(documentActions.startPolling(documentId)); + } + }); }, [dispatch, documentId, isPaused]); const showProcessingToggle = From b2191242a160572e6f23599824dbd94f8ac3bfea Mon Sep 17 00:00:00 2001 From: Yehor Makarenko Date: Tue, 22 Sep 2026 10:34:22 +0300 Subject: [PATCH 5/5] TSA-328: * improve status handling --- .../documents/slices/documents.slice.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/modules/documents/slices/documents.slice.ts b/apps/frontend/src/modules/documents/slices/documents.slice.ts index 34cd3751..8332acb1 100644 --- a/apps/frontend/src/modules/documents/slices/documents.slice.ts +++ b/apps/frontend/src/modules/documents/slices/documents.slice.ts @@ -90,12 +90,18 @@ const { actions, name, reducer } = createSlice({ }); builder.addCase(pollDocumentById.fulfilled, (state, action) => { if (state.document && state.document.id === action.payload.id) { - const nextStatus = - state.document.status === DocumentStatus.PAUSED - ? DocumentStatus.PAUSED - : action.payload.status; + const isProcessingOrPaused = + action.payload.status === DocumentStatus.PROCESSING || + action.payload.status === DocumentStatus.PAUSED; - state.document = { ...action.payload, status: nextStatus }; + if ( + state.document.status === DocumentStatus.PAUSED && + isProcessingOrPaused + ) { + return; + } + + state.document = { ...action.payload }; } }); builder.addCase(remove.fulfilled, (state, action) => { @@ -109,10 +115,6 @@ const { actions, name, reducer } = createSlice({ }); builder.addCase(pause.fulfilled, (state, action) => { state.pauseResumeDataStatuses[action.meta.arg] = DataStatus.FULFILLED; - - if (state.document && state.document.id === action.meta.arg) { - state.document.status = DocumentStatus.PAUSED; - } }); builder.addCase(resume.fulfilled, (state, action) => { state.pauseResumeDataStatuses[action.meta.arg] = DataStatus.FULFILLED; @@ -138,7 +140,11 @@ const { actions, name, reducer } = createSlice({ builder.addCase(pause.rejected, (state, action) => { state.pauseResumeDataStatuses[action.meta.arg] = DataStatus.REJECTED; - if (state.document && state.document.id === action.meta.arg) { + if ( + state.document && + state.document.id === action.meta.arg && + state.document.status === DocumentStatus.PAUSED + ) { state.document.status = DocumentStatus.PROCESSING; } });