// What a reviewer reads and does: the document list, one document's lines, and an edit. // // Approving and rejecting are the proposals module's own procedures; this file adds only what that // module has no word for. A document's status and counts are computed from the two places they // live (the document row and the proposals), never stored twice. import { notFound } from "@conusai-kit/core" import { type FileView, getFile, listFiles } from "@conusai-kit/files" import { editProposal, getProposal } from "@conusai-kit/proposals" import { type DocumentView, documents, LINE_TARGET_KEY, type LineView, lineChangeSchema, READABLE_CONTENT_TYPES, } from "../contract/index.js" import { type DocumentScope, type DocumentsDeps, lineProposals, lineProposalsByDocument, lineViewOf, visibleLines, } from "./lines.js" const PAGE = 200 const readable = (file: FileView): boolean => (READABLE_CONTENT_TYPES as readonly string[]).includes(file.contentType) async function allFiles(deps: DocumentsDeps, scope: DocumentScope): Promise { const found: FileView[] = [] let cursor: string | undefined do { const page = await listFiles(deps.files, scope, { limit: PAGE, ...(cursor === undefined ? {} : { cursor }) }) found.push(...page.files) cursor = page.hasMore ? page.cursor : undefined } while (cursor !== undefined) return found.filter(readable) } async function allDocuments(deps: DocumentsDeps, scope: DocumentScope) { const found = [] let cursor: string | undefined do { const page = await deps.data .forScope(scope) .of(documents) .list({ limit: PAGE, ...(cursor === undefined ? {} : { cursor }) }) found.push(...page.items) cursor = page.hasMore ? page.cursor : undefined } while (cursor !== undefined) return found } function viewOf( file: FileView, row: Awaited>[number] | undefined, lines: readonly LineView[], ): DocumentView { return { fileId: file.fileId, filename: file.filename, // An upload the job has not picked up yet has no row: it is queued, which is a fact about the // queue and not a state the module stores. status: row?.status ?? "queued", failureCode: row?.failureCode ?? null, pageCount: row?.pageCount ?? null, skippedPages: row?.skippedPages ?? [], pending: lines.filter((line) => line.status === "pending").length, approved: lines.filter((line) => line.status === "approved").length, uploadedAt: file.storedAt, } } export async function listDocuments(deps: DocumentsDeps, scope: DocumentScope): Promise { const [files, rows] = await Promise.all([allFiles(deps, scope), allDocuments(deps, scope)]) // The proposals of these documents only, pre-indexed by document id: one indexed read of the // proposals table (its target index) and a Map lookup per document, instead of the project's // whole queue read into memory and re-filtered for every row (O(D * P)). The list itself is // answered whole because the `documents.document.list` contract takes no cursor; paging it // through the procedure is a contract change, and the read that was unbounded was this one. const byDocument = await lineProposalsByDocument( deps, scope, rows.map((row) => row.id), ) const byFile = new Map(rows.map((row) => [row.fileId, row])) return files.map((file) => { const row = byFile.get(file.fileId) const own = row ? visibleLines(byDocument.get(row.id) ?? []) : [] return viewOf(file, row, own) }) } export async function getDocument( deps: DocumentsDeps, scope: DocumentScope, fileId: string, ): Promise<{ document: DocumentView; lines: LineView[] }> { // NotFound for another tenant's file id and for an id nobody has: the files module answers both // the same way, and so does this one (spec ยง3.3). const file = await getFile(deps.files, scope, fileId) if (!readable(file)) throw notFound("no such document") const row = ( await deps.data .forScope(scope) .of(documents) .list({ where: [{ field: "fileId", op: "eq", value: file.fileId }], limit: 1 }) ).items[0] const lines = row ? visibleLines(await lineProposals(deps, scope, row.id)) : [] return { document: viewOf(file, row, lines), lines } } /** * A reviewer's correction of a pending line: the proposals module replaces the proposal's change with * the corrected one (validated by this module's own schema, audited, and refused once decided), and * what is approved afterwards is what the reviewer saw. The line keeps its id. */ export async function editLine( deps: DocumentsDeps, scope: DocumentScope, input: { lineId: string; label: string; value: string }, ): Promise { const current = await getProposal(deps.proposals, scope, input.lineId) if (current.targetKey !== LINE_TARGET_KEY) throw notFound("no such line") const change = lineChangeSchema.parse(current.change) const edited = await editProposal(deps.proposals, scope, current.proposalId, { ...change, label: input.label, value: input.value, edited: true, }) return lineViewOf(edited) }