This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// 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<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 {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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<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,
|
||||
})
|
||||
return lineViewOf(edited)
|
||||
}
|
||||
Reference in New Issue
Block a user