diff --git a/apps/backend/src/db/migrations/20260920100000_add_page_event_attempt.ts b/apps/backend/src/db/migrations/20260920100000_add_page_event_attempt.ts new file mode 100644 index 00000000..d1c68335 --- /dev/null +++ b/apps/backend/src/db/migrations/20260920100000_add_page_event_attempt.ts @@ -0,0 +1,38 @@ +import { type Knex } from "knex"; + +const TABLE_NAME = "page_event"; +const ATTEMPT_COLUMN = "attempt"; +const DEFAULT_ATTEMPT = 0; +const ONCE_INDEX = "page_event_once"; + +async function down(knex: Knex): Promise { + await knex.raw(` + DROP INDEX IF EXISTS ${ONCE_INDEX}; + `); + + await knex.schema.alterTable(TABLE_NAME, (table) => { + table.dropColumn(ATTEMPT_COLUMN); + }); + + await knex.raw(` + CREATE UNIQUE INDEX ${ONCE_INDEX} + ON ${TABLE_NAME} (page_id, transcription_id, event) + WHERE event IN ('confirm', 'correct', 'skip'); + `); +} + +async function up(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, (table) => { + table.integer(ATTEMPT_COLUMN).notNullable().defaultTo(DEFAULT_ATTEMPT); + }); + + await knex.raw(` + DROP INDEX ${ONCE_INDEX}; + + CREATE UNIQUE INDEX ${ONCE_INDEX} + ON ${TABLE_NAME} (page_id, transcription_id, event, attempt) + WHERE event IN ('confirm', 'correct', 'skip'); + `); +} + +export { down, up }; diff --git a/apps/backend/src/modules/documents/document.repository.ts b/apps/backend/src/modules/documents/document.repository.ts index 5a46783d..a99ba216 100644 --- a/apps/backend/src/modules/documents/document.repository.ts +++ b/apps/backend/src/modules/documents/document.repository.ts @@ -262,6 +262,18 @@ class DocumentRepository { .execute(); } + public async setCursorPageNo( + documentId: number, + cursorPageNo: number, + trx: Transaction, + ): Promise { + await this.documentModel + .query(trx) + .patch({ cursorPageNo }) + .where({ id: documentId }) + .execute(); + } + public async setError(id: number, errorMessage: string): Promise { await this.documentModel .query() diff --git a/apps/backend/src/modules/jobs/libs/enums/page-event-name.enum.ts b/apps/backend/src/modules/jobs/libs/enums/page-event-name.enum.ts index e2617dc8..57bc0e33 100644 --- a/apps/backend/src/modules/jobs/libs/enums/page-event-name.enum.ts +++ b/apps/backend/src/modules/jobs/libs/enums/page-event-name.enum.ts @@ -1,5 +1,6 @@ const PageEventName = { TRANSCRIBE_FAILED: "transcribe_failed", + UNDO: "undo", } as const; export { PageEventName }; diff --git a/apps/backend/src/modules/pages/libs/constants/constants.ts b/apps/backend/src/modules/pages/libs/constants/constants.ts index 9a0f98f4..87f6ae5e 100644 --- a/apps/backend/src/modules/pages/libs/constants/constants.ts +++ b/apps/backend/src/modules/pages/libs/constants/constants.ts @@ -1,3 +1,5 @@ export { CLOSED_PAGE_STATUSES } from "./closed-page-statuses.constant.js"; +export { PAGE_EVENT_ATTEMPT_INCREMENT } from "./page-event-attempt-increment.constant.js"; export { NUMBER_OF_PAGES_TO_INCREMENT } from "./page-number-increment.constant.js"; export { REPROCESSABLE_PAGE_STATUSES } from "./reprocessable-page-statuses.constant.js"; +export { UNDOABLE_PAGE_STATUSES } from "./undoable-page-statuses.constant.js"; diff --git a/apps/backend/src/modules/pages/libs/constants/page-event-attempt-increment.constant.ts b/apps/backend/src/modules/pages/libs/constants/page-event-attempt-increment.constant.ts new file mode 100644 index 00000000..45bc4838 --- /dev/null +++ b/apps/backend/src/modules/pages/libs/constants/page-event-attempt-increment.constant.ts @@ -0,0 +1,3 @@ +const PAGE_EVENT_ATTEMPT_INCREMENT = 1; + +export { PAGE_EVENT_ATTEMPT_INCREMENT }; diff --git a/apps/backend/src/modules/pages/libs/constants/undoable-page-statuses.constant.ts b/apps/backend/src/modules/pages/libs/constants/undoable-page-statuses.constant.ts new file mode 100644 index 00000000..75c9ecdd --- /dev/null +++ b/apps/backend/src/modules/pages/libs/constants/undoable-page-statuses.constant.ts @@ -0,0 +1,9 @@ +import { PageStatus, type PageStatusValue } from "@transcripta/shared"; + +const UNDOABLE_PAGE_STATUSES: ReadonlySet = new Set([ + PageStatus.CONFIRMED, + PageStatus.CORRECTED, + PageStatus.SKIPPED, +]); + +export { UNDOABLE_PAGE_STATUSES }; diff --git a/apps/backend/src/modules/pages/libs/enums/page-error-message.enum.ts b/apps/backend/src/modules/pages/libs/enums/page-error-message.enum.ts index b838642c..7e296349 100644 --- a/apps/backend/src/modules/pages/libs/enums/page-error-message.enum.ts +++ b/apps/backend/src/modules/pages/libs/enums/page-error-message.enum.ts @@ -1,6 +1,7 @@ const PageErrorMessage = { PAGE_NOT_FOUND: "Page not found", PAGE_NOT_REPROCESSABLE: "Page cannot be reprocessed", + PAGE_NOT_VERIFIED: "Page is not verified", REPROCESS_FAILED: "Failed to reprocess page", TEXT_REQUIRED_FOR_CORRECTION: "Text is required for correction", TRANSCRIPTION_NOT_FOUND: "Transcription is no longer current", diff --git a/apps/backend/src/modules/pages/page-event/libs/types/page-event-create-payload.type.ts b/apps/backend/src/modules/pages/page-event/libs/types/page-event-create-payload.type.ts index 7140e0d8..d3773b6b 100644 --- a/apps/backend/src/modules/pages/page-event/libs/types/page-event-create-payload.type.ts +++ b/apps/backend/src/modules/pages/page-event/libs/types/page-event-create-payload.type.ts @@ -2,6 +2,7 @@ import { type PageVerificationActionValue } from "@transcripta/shared"; type CreatePageEventPayload = { actorId: number; + attempt: number; documentId: number; durationMs: number; event: PageVerificationActionValue; diff --git a/apps/backend/src/modules/pages/page-event/libs/types/page-event-create-undo-payload.type.ts b/apps/backend/src/modules/pages/page-event/libs/types/page-event-create-undo-payload.type.ts new file mode 100644 index 00000000..3cddbdbe --- /dev/null +++ b/apps/backend/src/modules/pages/page-event/libs/types/page-event-create-undo-payload.type.ts @@ -0,0 +1,9 @@ +type CreateUndoPageEventPayload = { + actorId: number; + attempt: number; + documentId: number; + pageId: number; + transcriptionId: number; +}; + +export { type CreateUndoPageEventPayload }; diff --git a/apps/backend/src/modules/pages/page-event/libs/types/page-event-find-verification-event.type.ts b/apps/backend/src/modules/pages/page-event/libs/types/page-event-find-verification-event.type.ts index 18ff5987..7961ced6 100644 --- a/apps/backend/src/modules/pages/page-event/libs/types/page-event-find-verification-event.type.ts +++ b/apps/backend/src/modules/pages/page-event/libs/types/page-event-find-verification-event.type.ts @@ -1,6 +1,7 @@ import { type PageVerificationActionValue } from "@transcripta/shared"; type FindVerificationEventPayload = { + attempt: number; event: PageVerificationActionValue; pageId: number; transcriptionId: number; diff --git a/apps/backend/src/modules/pages/page-event/libs/types/types.ts b/apps/backend/src/modules/pages/page-event/libs/types/types.ts index 6f484ad8..180b799c 100644 --- a/apps/backend/src/modules/pages/page-event/libs/types/types.ts +++ b/apps/backend/src/modules/pages/page-event/libs/types/types.ts @@ -1,2 +1,3 @@ export { type CreatePageEventPayload } from "./page-event-create-payload.type.js"; +export { type CreateUndoPageEventPayload } from "./page-event-create-undo-payload.type.js"; export { type FindVerificationEventPayload } from "./page-event-find-verification-event.type.js"; diff --git a/apps/backend/src/modules/pages/page-event/page-event.model.ts b/apps/backend/src/modules/pages/page-event/page-event.model.ts index 2c286bd8..b7785efd 100644 --- a/apps/backend/src/modules/pages/page-event/page-event.model.ts +++ b/apps/backend/src/modules/pages/page-event/page-event.model.ts @@ -6,6 +6,8 @@ import { class PageEventModel extends AbstractModel { public actorId!: null | number; + public attempt!: number; + public details!: Record; public documentId!: number; diff --git a/apps/backend/src/modules/pages/page-event/page-event.repository.ts b/apps/backend/src/modules/pages/page-event/page-event.repository.ts index 1ccc9211..779c6821 100644 --- a/apps/backend/src/modules/pages/page-event/page-event.repository.ts +++ b/apps/backend/src/modules/pages/page-event/page-event.repository.ts @@ -1,7 +1,12 @@ import { type Transaction } from "objection"; +import { PageEventName } from "~/modules/jobs/libs/enums/enums.js"; + +const INITIAL_PAGE_EVENT_ATTEMPT = 0; + import { type CreatePageEventPayload, + type CreateUndoPageEventPayload, type FindVerificationEventPayload, } from "./libs/types/types.js"; import { type PageEventModel } from "./page-event.model.js"; @@ -13,6 +18,20 @@ class PageEventRepository { this.pageEventModel = pageEventModel; } + public async createUndoEvent( + payload: CreateUndoPageEventPayload, + trx?: Transaction, + ): Promise { + await this.pageEventModel + .query(trx) + .insert({ + ...payload, + details: {}, + event: PageEventName.UNDO, + }) + .execute(); + } + public async createVerificationEvent( payload: CreatePageEventPayload, trx?: Transaction, @@ -26,6 +45,20 @@ class PageEventRepository { .execute(); } + public async findLatestAttempt( + pageId: number, + trx?: Transaction, + ): Promise { + const event = await this.pageEventModel + .query(trx) + .where({ pageId }) + .orderBy("attempt", "desc") + .first() + .execute(); + + return event?.attempt ?? INITIAL_PAGE_EVENT_ATTEMPT; + } + public async findVerificationEvent( payload: FindVerificationEventPayload, trx?: Transaction, diff --git a/apps/backend/src/modules/pages/page.controller.ts b/apps/backend/src/modules/pages/page.controller.ts index 97247fb5..24f90845 100644 --- a/apps/backend/src/modules/pages/page.controller.ts +++ b/apps/backend/src/modules/pages/page.controller.ts @@ -8,11 +8,13 @@ import { import { APIPath } from "~/libs/enums/enums.js"; import { authGuard } from "~/libs/modules/auth/auth.js"; import { + type APIHandlerOptions, type APIHandlerResponse, BaseController, } from "~/libs/modules/controller/controller.js"; import { HTTPCode, HTTPMethod } from "~/libs/modules/http/http.js"; import { type Logger } from "~/libs/modules/logger/logger.js"; +import { type TokenPayload } from "~/libs/modules/token/token.js"; import { type GetPageDebugHandlerOptions, @@ -21,6 +23,13 @@ import { } from "./libs/types/types.js"; import { type PageService } from "./page.service.js"; +type UndoPageHandlerOptions = APIHandlerOptions<{ + params: { + id: number; + }; + user: TokenPayload; +}>; + class PageController extends BaseController { private pageService: PageService; @@ -61,6 +70,16 @@ class PageController extends BaseController { params: reprocessPageParameters, }, }); + + this.addRoute({ + handler: (options) => this.undo(options as UndoPageHandlerOptions), + method: HTTPMethod.POST, + path: PageApiPath.UNDO, + preHandler: authGuard, + validation: { + params: verifyPageParameters, + }, + }); } /** @@ -133,6 +152,40 @@ class PageController extends BaseController { }; } + /** + * @swagger + * /pages/{id}/undo: + * post: + * description: Undo the latest verification of a page + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * responses: + * 200: + * description: Page verification undone + * 404: + * description: Page not found + * 409: + * description: Page is not verified + */ + private async undo( + options: UndoPageHandlerOptions, + ): Promise { + return { + payload: await this.pageService.undo( + options.params.id, + options.user.userId, + ), + status: HTTPCode.OK, + }; + } + /** * @swagger * /pages/{id}/verify: diff --git a/apps/backend/src/modules/pages/page.service.ts b/apps/backend/src/modules/pages/page.service.ts index d2271e3b..629071a3 100644 --- a/apps/backend/src/modules/pages/page.service.ts +++ b/apps/backend/src/modules/pages/page.service.ts @@ -2,7 +2,9 @@ import { HTTPCode, HTTPError, type PageDebugResponseDto, + PageStatus, PageVerificationAction, + type UndoPageResponseDto, type VerifyPageResponseDto, } from "@transcripta/shared"; import { type Transaction, UniqueViolationError } from "objection"; @@ -16,7 +18,9 @@ import { type TranscriptionRepository } from "../transcription/transcription.rep import { CLOSED_PAGE_STATUSES, NUMBER_OF_PAGES_TO_INCREMENT, + PAGE_EVENT_ATTEMPT_INCREMENT, REPROCESSABLE_PAGE_STATUSES, + UNDOABLE_PAGE_STATUSES, } from "./libs/constants/constants.js"; import { PageErrorMessage, @@ -226,6 +230,105 @@ class PageService { } } + public async undo( + pageId: number, + userId: number, + ): Promise { + return await DocumentModel.transaction(async (trx) => { + const initialPage = await this.pageRepository.findByIdForOwner( + pageId, + userId, + trx, + ); + + if (!initialPage) { + throw new HTTPError({ + message: PageErrorMessage.PAGE_NOT_FOUND, + status: HTTPCode.NOT_FOUND, + }); + } + + const document = + await this.documentRepository.findByIdAndOwnerIdForUpdate( + initialPage.documentId, + userId, + trx, + ); + const page = await this.pageRepository.findByIdForOwner( + pageId, + userId, + trx, + ); + + if (!document || !page) { + throw new HTTPError({ + message: PageErrorMessage.PAGE_NOT_FOUND, + status: HTTPCode.NOT_FOUND, + }); + } + + if (!UNDOABLE_PAGE_STATUSES.has(page.status)) { + throw new HTTPError({ + message: PageErrorMessage.PAGE_NOT_VERIFIED, + status: HTTPCode.CONFLICT, + }); + } + + const transcription = + await this.transcriptionRepository.findCurrentByPageId(pageId, trx); + + if (!transcription) { + throw new HTTPError({ + message: PageErrorMessage.TRANSCRIPTION_NOT_FOUND, + status: HTTPCode.CONFLICT, + }); + } + + const attempt = + (await this.pageEventRepository.findLatestAttempt(pageId, trx)) + + PAGE_EVENT_ATTEMPT_INCREMENT; + + await this.pageRepository.updateVerification( + { + pageId, + status: PageStatus.TRANSCRIBED, + verifiedAt: null, + verifiedBy: null, + }, + trx, + ); + + await this.pageEventRepository.createUndoEvent( + { + actorId: userId, + attempt, + documentId: page.documentId, + pageId, + transcriptionId: transcription.id, + }, + trx, + ); + + await this.documentRepository.setCursorPageNo( + page.documentId, + page.pageNo, + trx, + ); + await this.documentRepository.markProcessingIfDone(page.documentId, trx); + + return { + pageId, + status: PageStatus.TRANSCRIBED, + transcription: { + contextWords: [], + id: transcription.id, + structured: transcription.structured, + text: transcription.text, + }, + }; + }); + } + public async verify( payload: VerifyPagePayload, ): Promise { @@ -284,9 +387,15 @@ class PageService { }); } + const attempt = await this.pageEventRepository.findLatestAttempt( + pageId, + trx, + ); + const existingEvent = await this.pageEventRepository.findVerificationEvent( { + attempt, event: action, pageId, transcriptionId, @@ -333,6 +442,7 @@ class PageService { await this.pageEventRepository.createVerificationEvent( { actorId: userId, + attempt, documentId: page.documentId, durationMs: payload.durationMs, event: action, diff --git a/docs/05-api.md b/docs/05-api.md index 97718e11..26a9a493 100644 --- a/docs/05-api.md +++ b/docs/05-api.md @@ -66,6 +66,7 @@ list below needs its own block — 19 blocks that nobody will write for us. | | | | | `GET` | `/api/v1/documents/:id/pages` | Pages with their transcriptions | | `POST` | `/api/v1/pages/:id/verify` | **The main endpoint** | +| `POST` | `/api/v1/pages/:id/undo` | Undo the latest page verification | | `GET` | `/api/v1/pages/:id/debug` | Prompt + raw response + context (owner only) | | `POST` | `/api/v1/pages/:id/reprocess` | Re-read a page | | | | | diff --git a/docs/06-verification-ui.md b/docs/06-verification-ui.md index ca955f28..22439e7a 100644 --- a/docs/06-verification-ui.md +++ b/docs/06-verification-ui.md @@ -245,7 +245,7 @@ The guard is a unique index on the history table, which is append-only anyway: ```sql CREATE UNIQUE INDEX page_event_once - ON page_event (page_id, transcription_id, event) + ON page_event (page_id, transcription_id, event, attempt) WHERE event IN ('confirm', 'correct', 'skip'); ``` @@ -255,10 +255,12 @@ the endpoint answers `200` with the current page state — the client sees success and drops the action from its queue. Both the column and the index are already in -[schema.sql](schema/schema.sql) — `page_event.transcription_id` and -`page_event_once`. The index is partial on purpose: `transcribed` and `failed` -are system events that legitimately repeat on a re-run, so only the three human -actions are constrained. +[schema.sql](schema/schema.sql) — `page_event.transcription_id`, +`page_event.attempt` and `page_event_once`. Undo increments `attempt`, so the +same human action after undo is a new attempt while a retry within the same +attempt is still absorbed. The index is partial on purpose: `transcribed` and +`failed` are system events that legitimately repeat on a re-run, so only the +three human actions are constrained. Answering `200` rather than `409` matters: a replay is not a conflict. The human did confirm this page against this transcription, and the outcome the diff --git a/docs/schema/schema.sql b/docs/schema/schema.sql index 1df9c123..1e79a2f8 100644 --- a/docs/schema/schema.sql +++ b/docs/schema/schema.sql @@ -360,6 +360,9 @@ CREATE TABLE page_event ( -- How long the human spent on the page. The headline product metric. duration_ms integer, + -- Undo increments this so a later confirmation is not a replay. + attempt integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), -- append-only, but the column is required by the base model's $beforeInsert updated_at timestamptz NOT NULL DEFAULT now() @@ -372,7 +375,7 @@ CREATE TABLE page_event ( -- the prompt without having earned it. -- Partial: only human actions are unique. transcribed/failed repeat on re-runs. CREATE UNIQUE INDEX page_event_once - ON page_event (page_id, transcription_id, event) + ON page_event (page_id, transcription_id, event, attempt) WHERE event IN ('confirm', 'correct', 'skip'); CREATE INDEX page_event_page_idx ON page_event (page_id, created_at DESC); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1f88f08f..4f1f322d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -75,6 +75,7 @@ export { type PageDebugResponseDto, type PageStatusValue, type PageVerificationActionValue, + type UndoPageResponseDto, type VerifyPageRequestDto, type VerifyPageResponseDto, PageApiPath, diff --git a/packages/shared/src/modules/pages/libs/enums/page-api-path.enum.ts b/packages/shared/src/modules/pages/libs/enums/page-api-path.enum.ts index 8488ec78..57028567 100644 --- a/packages/shared/src/modules/pages/libs/enums/page-api-path.enum.ts +++ b/packages/shared/src/modules/pages/libs/enums/page-api-path.enum.ts @@ -2,6 +2,7 @@ const PageApiPath = { DEBUG: "/:id/debug", REPROCESS: "/:id/reprocess", ROOT: "/", + UNDO: "/:id/undo", VERIFY: "/:id/verify", } as const; diff --git a/packages/shared/src/modules/pages/libs/types/page-undo-response-dto.type.ts b/packages/shared/src/modules/pages/libs/types/page-undo-response-dto.type.ts new file mode 100644 index 00000000..af251336 --- /dev/null +++ b/packages/shared/src/modules/pages/libs/types/page-undo-response-dto.type.ts @@ -0,0 +1,10 @@ +import { type DocumentGetPagesTranscriptionResponseDto } from "../../../documents/libs/types/document-get-pages-transcription-response-dto.type.js"; +import { type PageStatusValue } from "./page-status-value.type.js"; + +type UndoPageResponseDto = { + pageId: number; + status: PageStatusValue; + transcription: DocumentGetPagesTranscriptionResponseDto | null; +}; + +export { type UndoPageResponseDto }; diff --git a/packages/shared/src/modules/pages/libs/types/types.ts b/packages/shared/src/modules/pages/libs/types/types.ts index f95c828b..5bdf40ef 100644 --- a/packages/shared/src/modules/pages/libs/types/types.ts +++ b/packages/shared/src/modules/pages/libs/types/types.ts @@ -1,6 +1,7 @@ export type { ContextWordDto } from "./page-context-word.dto.type.js"; export type { PageDebugResponseDto } from "./page-debug-response-dto.type.js"; export type { PageStatusValue } from "./page-status-value.type.js"; +export type { UndoPageResponseDto } from "./page-undo-response-dto.type.js"; export type { PageVerificationActionValue } from "./page-verification-action-value.type.js"; export type { VerifyPageLexiconItemDto } from "./page-verify-lexicon-item.type.js"; export type { VerifyPageNextDto } from "./page-verify-next-page.dto.type.js"; diff --git a/packages/shared/src/modules/pages/pages.ts b/packages/shared/src/modules/pages/pages.ts index 0329ef15..44adaf24 100644 --- a/packages/shared/src/modules/pages/pages.ts +++ b/packages/shared/src/modules/pages/pages.ts @@ -7,6 +7,7 @@ export { type PageDebugResponseDto, type PageStatusValue, type PageVerificationActionValue, + type UndoPageResponseDto, type VerifyPageRequestDto, type VerifyPageResponseDto, } from "./libs/types/types.js";