135 lines
5.0 KiB
TypeScript
135 lines
5.0 KiB
TypeScript
// 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)
|
|
}
|