Files

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)
}