@@ -0,0 +1,178 @@
|
||||
// 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 { AppError, 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<FileView[]> {
|
||||
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<ReturnType<typeof allDocuments>>[number] | undefined,
|
||||
lines: readonly LineView[],
|
||||
): DocumentView {
|
||||
return {
|
||||
documentId: row?.id ?? null,
|
||||
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<DocumentView[]> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
/** The most ids one batch-get reads (contract C-05). */
|
||||
export const MAX_BATCH = 100
|
||||
|
||||
/**
|
||||
* The live collection's read-back: the documents whose rows have these ids, each with its file and its
|
||||
* line counts. A row this caller cannot see is not in the answer, and neither is one whose file has gone
|
||||
* or is no longer a document this module reads -- the list would not show it either.
|
||||
*/
|
||||
export async function getManyDocuments(
|
||||
deps: DocumentsDeps,
|
||||
scope: DocumentScope,
|
||||
documentIds: readonly string[],
|
||||
): Promise<DocumentView[]> {
|
||||
const rows = (
|
||||
await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({ where: [{ field: "id", op: "in", values: [...new Set(documentIds)] }], limit: MAX_BATCH })
|
||||
).items
|
||||
const byDocument = await lineProposalsByDocument(
|
||||
deps,
|
||||
scope,
|
||||
rows.map((row) => row.id),
|
||||
)
|
||||
const views: DocumentView[] = []
|
||||
for (const row of rows) {
|
||||
const file = await fileOf(deps, scope, row.fileId)
|
||||
if (file && readable(file)) views.push(viewOf(file, row, visibleLines(byDocument.get(row.id) ?? [])))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
/** The file of a row, or nothing when the files module answers it is not there (deleted, or never this tenant's). */
|
||||
async function fileOf(deps: DocumentsDeps, scope: DocumentScope, fileId: string): Promise<FileView | undefined> {
|
||||
try {
|
||||
return await getFile(deps.files, scope, fileId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.status === 404) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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; expectedVersion: number; label: string; value: string },
|
||||
): Promise<LineView> {
|
||||
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 },
|
||||
input.expectedVersion,
|
||||
)
|
||||
return lineViewOf(edited)
|
||||
}
|
||||
Reference in New Issue
Block a user