diff --git a/deploy/preview/deploy-local.sh b/deploy/preview/deploy-local.sh index e77ccbf..90e220f 100644 --- a/deploy/preview/deploy-local.sh +++ b/deploy/preview/deploy-local.sh @@ -67,8 +67,8 @@ deploy() { # The policy itself contains no endpoint or credential, but a configured # transport without a policy would otherwise silently become the disabled - # "[]" configuration below. Flatten formatted JSON before writing the - # Compose dotenv file and fail before replacing a preview with rule-only AI. + # "[]" configuration below. Flatten formatted JSON before handing it to + # Compose and fail before replacing a preview with rule-only AI. ai_providers_json="$(printf '%s' "${ANGUI_AI_PROVIDERS_JSON:-}" | tr -d '\r\n')" ai_policy_without_whitespace="$(printf '%s' "${ai_providers_json}" | tr -d '[:space:]')" if { [[ -z "${ai_policy_without_whitespace}" ]] || [[ "${ai_policy_without_whitespace}" == "[]" ]]; } \ @@ -76,6 +76,14 @@ deploy() { die "ANGUI_AI_PROVIDERS_JSON is required when ANGUI_PREVIEW_AI_ENDPOINT or ANGUI_PREVIEW_AI_KEY is set" fi + # Keep AI values out of the Compose dotenv file. A dotenv file is line based, + # so even a valid formatted JSON policy can be split into unrelated entries + # before Compose resolves the api environment. Process environment values take + # precedence over --env-file values during Compose interpolation. + export ANGUI_AI_PROVIDERS_JSON="${ai_providers_json:-[]}" + export ANGUI_PREVIEW_AI_ENDPOINT="${ANGUI_PREVIEW_AI_ENDPOINT:-}" + export ANGUI_PREVIEW_AI_KEY="${ANGUI_PREVIEW_AI_KEY:-}" + PREVIEW_DIR="${PREVIEW_ROOT}/${PREVIEW_ID}" ensure_preview_dir [[ -f "${PREVIEW_BACKEND_DIR}/angui" ]] || die "backend artifact angui is missing" @@ -83,6 +91,10 @@ deploy() { [[ -f "${PREVIEW_BACKEND_DIR}/migration" ]] || die "backend artifact migration is missing" [[ -d "${PREVIEW_FRONTEND_DIR}" ]] || die "frontend artifact directory is missing" + if ! "${PREVIEW_BACKEND_DIR}/angui" validate-ai-config; then + die "ANGUI_AI_PROVIDERS_JSON failed application configuration validation" + fi + ensure_runtime_image ensure_proxy install -d -m 700 "${PREVIEW_DIR}/runtime" @@ -105,9 +117,6 @@ PREVIEW_ORIGIN=${PREVIEW_ORIGIN} PREVIEW_DEMO_PASSWORD=${PREVIEW_DEMO_PASSWORD} PREVIEW_PROXY_NETWORK=${PREVIEW_PROXY_NETWORK:-angui-proxy} AMAP_WEBSERVICE_KEY=${AMAP_WEBSERVICE_KEY:-} -ANGUI_AI_PROVIDERS_JSON=${ai_providers_json:-[]} -ANGUI_PREVIEW_AI_ENDPOINT=${ANGUI_PREVIEW_AI_ENDPOINT:-} -ANGUI_PREVIEW_AI_KEY=${ANGUI_PREVIEW_AI_KEY:-} EOF # A preview is a fresh, disposable environment. Removing its named SQLite diff --git a/docs/API.md b/docs/API.md index fda4cb0..beba2de 100644 --- a/docs/API.md +++ b/docs/API.md @@ -51,6 +51,9 @@ | `PATCH` | `/api/admin/users/{user_id}/status` | `200` | 管理员启用、停用或锁定账号 | | `POST` | `/api/admin/archive-drafts/{draft_id}/deidentify` | `200` | 管理员记录归档草稿的人工脱敏确认或拒绝 | | `PATCH` | `/api/admin/archive-drafts/{draft_id}/review` | `200` | 管理员发布、拒绝或撤回已脱敏归档草稿 | +| `GET` | `/api/admin/archive-drafts/{draft_id}/review-materials` | `200` | 管理员查看不可变 review material 版本链和当前 AI 输入版本 | +| `GET` | `/api/admin/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}` | `200` | 管理员比较两个脱敏材料版本 | +| `POST` | `/api/admin/archive-drafts/{draft_id}/review-materials/{version}/restore` | `200` | 管理员从历史已脱敏版本创建新的恢复版本 | 除健康检查和登录外,所有接口都要求: @@ -322,6 +325,8 @@ Example: `POST /api/admin/archive-drafts/{draft_id}/deidentify` 只接受人工 `confirm` 或 `reject` 结果、长度受限的理由,以及在确认时提交的人工脱敏文本。确认会创建新的已脱敏材料版本,再以该版本作为唯一允许交给 AI 的输入,并把草稿转为 `pending_review` 和 `deidentified`;拒绝会转为 `rejected`,不能发布。每次结果记录操作者、时间、版本和理由长度,审计不保存理由原文。 `PATCH /api/admin/archive-drafts/{draft_id}/review` 只允许已确认脱敏的 `pending_review` 草稿 `publish` 或 `reject`,以及将 `published` 草稿 `withdraw`。发布仅把该受控版本标记为 `learning_resource`;当前不会创建知识问答/RAG 索引、导出、打印、公开读取或跨案件读取接口。撤回立即将用途恢复为 `internal_archive` 并标记 `withdrawn`,同时保留版本和审计历史;拒绝或撤回绝不修改或删除原案件。 + +管理员可通过 `GET /api/admin/archive-drafts/{draft_id}/review-materials` 查看不可变的 review material 版本链、来源范围、审核状态和当前 AI 输入版本;`GET .../review-materials/diff/{from_version}/{to_version}` 提供两个版本的增删行差异。`POST .../review-materials/{version}/restore` 只能恢复管理员已确认的 `deidentified` 版本,恢复操作会生成带 `parent_material_id` 的新版本、切换草稿的 AI 输入指针并将草稿重新置为 `pending_review`,不会修改任何历史版本。审计只记录版本、ID 和理由长度,不记录材料原文。 # Controlled AI draft endpoints The following authenticated endpoints produce only drafts; none confirms a clue, publishes a summary, dispatches a task, or inserts material into a knowledge base automatically. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e2983a6..a1e095c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1693,6 +1693,87 @@ paths: "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } + /api/admin/archive-drafts/{draft_id}/review-materials: + parameters: + - name: draft_id + in: path + required: true + schema: { type: string, format: uuid } + get: + tags: [Admin] + operationId: listArchiveReviewMaterials + summary: List immutable archive review-material versions + description: Admin capability only. Returns the version chain, source-scope metadata, review state, and the de-identified material currently selected as the AI input. Raw source records are never returned by this endpoint. + security: [{ bearerAuth: [] }] + x-account-types: [member] + x-global-capabilities: [admin] + x-data-classification: restricted-admin + responses: + "200": { description: Archive review-material versions, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/ArchiveReviewMaterial" } } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/admin/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}: + parameters: + - name: draft_id + in: path + required: true + schema: { type: string, format: uuid } + - name: from_version + in: path + required: true + schema: { type: integer, minimum: 1 } + - name: to_version + in: path + required: true + schema: { type: integer, minimum: 1 } + get: + tags: [Admin] + operationId: diffArchiveReviewMaterials + summary: Compare two archive review-material versions + description: Admin capability only. Returns line-level additions and removals without mutating either immutable material version. + security: [{ bearerAuth: [] }] + x-account-types: [member] + x-global-capabilities: [admin] + x-data-classification: restricted-admin + responses: + "200": { description: Material version diff, content: { application/json: { schema: { $ref: "#/components/schemas/ArchiveReviewMaterialDiff" } } } } + "400": { $ref: "#/components/responses/ValidationError" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/admin/archive-drafts/{draft_id}/review-materials/{version}/restore: + parameters: + - name: draft_id + in: path + required: true + schema: { type: string, format: uuid } + - name: version + in: path + required: true + schema: { type: integer, minimum: 1 } + post: + tags: [Admin] + operationId: restoreArchiveReviewMaterial + summary: Restore an approved material version as a new immutable version + description: Admin capability only. The selected de-identified version is copied into a new version with a parent link, the archive draft points to that new version, and the draft returns to pending_review. No historical row is mutated. + security: [{ bearerAuth: [] }] + x-account-types: [member] + x-global-capabilities: [admin] + x-data-classification: restricted-admin + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/RestoreArchiveReviewMaterialRequest" } } } + responses: + "200": { description: Archive draft regenerated from the restored approved material, content: { application/json: { schema: { $ref: "#/components/schemas/ArchiveDraft" } } } } + "400": { $ref: "#/components/responses/ValidationError" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + components: securitySchemes: bearerAuth: @@ -3225,6 +3306,42 @@ components: created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } + ArchiveReviewMaterial: + type: object + additionalProperties: false + required: [id, case_id, version, parent_material_id, content, source_scope, status, created_by_user_id, reviewed_by_user_id, reviewed_at, review_reason, created_at, selected_for_ai] + properties: + id: { type: string, format: uuid } + case_id: { type: string, format: uuid } + version: { type: integer, minimum: 1 } + parent_material_id: { type: [string, "null"], format: uuid } + content: { type: string, description: Administrator-visible de-identified review material or the initial controlled material awaiting de-identification. } + source_scope: { type: array, items: { type: string } } + status: { type: string, enum: [draft, deidentified, rejected] } + created_by_user_id: { type: string, format: uuid } + reviewed_by_user_id: { type: [string, "null"], format: uuid } + reviewed_at: { type: [string, "null"], format: date-time } + review_reason: { type: [string, "null"], description: Review reason is returned only within the administrator boundary. } + created_at: { type: string, format: date-time } + selected_for_ai: { type: boolean } + + ArchiveReviewMaterialDiff: + type: object + additionalProperties: false + required: [from_version, to_version, added, removed] + properties: + from_version: { type: integer, minimum: 1 } + to_version: { type: integer, minimum: 1 } + added: { type: array, items: { type: string } } + removed: { type: array, items: { type: string } } + + RestoreArchiveReviewMaterialRequest: + type: object + additionalProperties: false + required: [reason] + properties: + reason: { type: string, minLength: 1, maxLength: 1000 } + DeidentifyArchiveDraftRequest: type: object additionalProperties: false diff --git a/frontend/src/api/cases.ts b/frontend/src/api/cases.ts index 8b5c493..a3029b3 100644 --- a/frontend/src/api/cases.ts +++ b/frontend/src/api/cases.ts @@ -365,6 +365,27 @@ export interface ArchiveDraft { created_at: string; updated_at: string; } +export interface ArchiveReviewMaterial { + id: string; + case_id: string; + version: number; + parent_material_id: string | null; + content: string; + source_scope: string[]; + status: "draft" | "deidentified" | "rejected"; + created_by_user_id: string; + reviewed_by_user_id: string | null; + reviewed_at: string | null; + review_reason: string | null; + created_at: string; + selected_for_ai: boolean; +} +export interface ArchiveReviewMaterialDiff { + from_version: number; + to_version: number; + added: string[]; + removed: string[]; +} export type TaskStatus = | "pending_claim" | "assigned" @@ -904,6 +925,43 @@ export function reviewArchiveDraft( ); } +export function listArchiveReviewMaterials( + token: string, + draftId: string, +): Promise { + return apiRequest( + `/admin/archive-drafts/${draftId}/review-materials`, + {}, + token, + ); +} + +export function diffArchiveReviewMaterials( + token: string, + draftId: string, + fromVersion: number, + toVersion: number, +): Promise { + return apiRequest( + `/admin/archive-drafts/${draftId}/review-materials/diff/${fromVersion}/${toVersion}`, + {}, + token, + ); +} + +export function restoreArchiveReviewMaterial( + token: string, + draftId: string, + version: number, + reason: string, +): Promise { + return apiRequest( + `/admin/archive-drafts/${draftId}/review-materials/${version}/restore`, + { method: "POST", body: JSON.stringify({ reason }) }, + token, + ); +} + export function listCaseClues( token: string, caseId: string, diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx index 541658a..49a89c1 100644 --- a/frontend/src/pages/DashboardPage.test.tsx +++ b/frontend/src/pages/DashboardPage.test.tsx @@ -1,9 +1,15 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DashboardPage } from "./DashboardPage"; const mocked = vi.hoisted(() => ({ + deidentifyArchiveDraft: vi.fn(), + reviewArchiveDraft: vi.fn(), getCase: vi.fn(), + listAdminArchiveDrafts: vi.fn(), + listArchiveReviewMaterials: vi.fn(), + diffArchiveReviewMaterials: vi.fn(), + restoreArchiveReviewMaterial: vi.fn(), listCases: vi.fn(), listCommandIntake: vi.fn(), globalCapabilities: [] as string[], @@ -22,15 +28,42 @@ vi.mock("../auth/useAuth", () => ({ }), })); vi.mock("../api/cases", () => ({ + deidentifyArchiveDraft: (...args: unknown[]) => + mocked.deidentifyArchiveDraft(...args), + reviewArchiveDraft: (...args: unknown[]) => mocked.reviewArchiveDraft(...args), getCase: (...args: unknown[]) => mocked.getCase(...args), listCases: (...args: unknown[]) => mocked.listCases(...args), listCommandIntake: (...args: unknown[]) => mocked.listCommandIntake(...args), + listAdminArchiveDrafts: (...args: unknown[]) => mocked.listAdminArchiveDrafts(...args), + listArchiveReviewMaterials: (...args: unknown[]) => + mocked.listArchiveReviewMaterials(...args), + diffArchiveReviewMaterials: (...args: unknown[]) => + mocked.diffArchiveReviewMaterials(...args), + restoreArchiveReviewMaterial: (...args: unknown[]) => + mocked.restoreArchiveReviewMaterial(...args), })); vi.mock("../components/ServiceStatus", () => ({ ServiceStatus: () => 服务状态, })); describe("DashboardPage", () => { + beforeEach(() => { + mocked.globalCapabilities = []; + mocked.listCases.mockResolvedValue([]); + mocked.deidentifyArchiveDraft.mockResolvedValue({}); + mocked.reviewArchiveDraft.mockResolvedValue({}); + mocked.listCommandIntake.mockResolvedValue([]); + mocked.listAdminArchiveDrafts.mockResolvedValue([]); + mocked.listArchiveReviewMaterials.mockResolvedValue([]); + mocked.diffArchiveReviewMaterials.mockResolvedValue({ + from_version: 1, + to_version: 2, + added: [], + removed: [], + }); + mocked.restoreArchiveReviewMaterial.mockResolvedValue({}); + }); + it("warns when the 20-detail statistics limit truncates the visible case list", async () => { mocked.listCases.mockResolvedValue( Array.from({ length: 21 }, (_, index) => ({ @@ -81,4 +114,162 @@ describe("DashboardPage", () => { expect(screen.getByText("待受理")).toBeInTheDocument(); expect(mocked.listCommandIntake).toHaveBeenCalledWith("test-session"); }); + + it("shows administrator material-version state for archive drafts", async () => { + mocked.globalCapabilities = ["admin"]; + mocked.listAdminArchiveDrafts.mockResolvedValue([ + { + id: "archive-1", + case_id: "case-1", + status: "pending_review", + content: "Timeline draft", + source_scope: ["confirmed_clue_review_material"], + review_material_id: "material-2", + deidentification_status: "deidentified", + template_version: "case-archive-ai-v2", + provider_model: null, + version: 2, + usage_scope: "internal_archive", + retention_status: "retained", + deidentified_at: "2026-07-25T08:00:00Z", + reviewed_at: null, + created_at: "2026-07-25T08:00:00Z", + updated_at: "2026-07-25T08:00:00Z", + }, + ]); + mocked.listArchiveReviewMaterials.mockResolvedValueOnce([ + { + id: "material-2", + case_id: "case-1", + version: 2, + parent_material_id: "material-1", + content: "Approved de-identified material", + source_scope: ["confirmed_clue_review_material"], + status: "deidentified", + created_by_user_id: "admin-1", + reviewed_by_user_id: "admin-1", + reviewed_at: "2026-07-25T08:00:00Z", + review_reason: "reviewed", + created_at: "2026-07-25T08:00:00Z", + selected_for_ai: true, + }, + { + id: "material-1", + case_id: "case-1", + version: 1, + parent_material_id: null, + content: "Earlier approved material", + source_scope: ["confirmed_clue_review_material"], + status: "deidentified", + created_by_user_id: "admin-1", + reviewed_by_user_id: "admin-1", + reviewed_at: "2026-07-24T08:00:00Z", + review_reason: "reviewed", + created_at: "2026-07-24T08:00:00Z", + selected_for_ai: false, + }, + ]); + mocked.listArchiveReviewMaterials.mockResolvedValueOnce([ + { + id: "material-3", + case_id: "case-1", + version: 3, + parent_material_id: "material-1", + content: "Restored approved material", + source_scope: ["confirmed_clue_review_material"], + status: "deidentified", + created_by_user_id: "admin-1", + reviewed_by_user_id: "admin-1", + reviewed_at: "2026-07-25T09:00:00Z", + review_reason: "restored", + created_at: "2026-07-25T09:00:00Z", + selected_for_ai: true, + }, + { + id: "material-2", + case_id: "case-1", + version: 2, + parent_material_id: "material-1", + content: "Approved de-identified material", + source_scope: ["confirmed_clue_review_material"], + status: "deidentified", + created_by_user_id: "admin-1", + reviewed_by_user_id: "admin-1", + reviewed_at: "2026-07-25T08:00:00Z", + review_reason: "reviewed", + created_at: "2026-07-25T08:00:00Z", + selected_for_ai: false, + }, + { + id: "material-1", + case_id: "case-1", + version: 1, + parent_material_id: null, + content: "Earlier approved material", + source_scope: ["confirmed_clue_review_material"], + status: "deidentified", + created_by_user_id: "admin-1", + reviewed_by_user_id: "admin-1", + reviewed_at: "2026-07-24T08:00:00Z", + review_reason: "reviewed", + created_at: "2026-07-24T08:00:00Z", + selected_for_ai: false, + }, + ]); + mocked.diffArchiveReviewMaterials.mockResolvedValue({ + from_version: 1, + to_version: 2, + added: ["Approved de-identified material"], + removed: ["Earlier approved material"], + }); + mocked.restoreArchiveReviewMaterial.mockResolvedValue({ + id: "archive-1", + case_id: "case-1", + status: "pending_review", + content: "Regenerated archive draft", + source_scope: ["confirmed_clue_review_material"], + review_material_id: "material-3", + deidentification_status: "deidentified", + template_version: "case-archive-ai-v2", + provider_model: null, + version: 3, + usage_scope: "internal_archive", + retention_status: "retained", + deidentified_at: "2026-07-25T08:00:00Z", + reviewed_at: null, + created_at: "2026-07-25T08:00:00Z", + updated_at: "2026-07-25T08:00:00Z", + }); + + render(); + + expect(await screen.findByText("审核材料版本")).toBeInTheDocument(); + expect( + await screen.findByText("Approved de-identified material"), + ).toBeInTheDocument(); + expect(screen.getByText(/当前 AI 输入/)).toBeInTheDocument(); + expect(mocked.listArchiveReviewMaterials).toHaveBeenCalledWith("test-session", "archive-1"); + + fireEvent.click(screen.getByRole("button", { name: "与最早版本比较" })); + expect(await screen.findByText("差异:v1 与 v2")).toBeInTheDocument(); + expect(mocked.diffArchiveReviewMaterials).toHaveBeenCalledWith( + "test-session", + "archive-1", + 1, + 2, + ); + + fireEvent.change(screen.getByLabelText(/审核理由/), { + target: { value: "restore approved historical version" }, + }); + fireEvent.click(screen.getByRole("button", { name: "恢复此版本" })); + expect(mocked.restoreArchiveReviewMaterial).toHaveBeenCalledWith( + "test-session", + "archive-1", + 1, + "restore approved historical version", + ); + expect(await screen.findByText("Restored approved material")).toBeInTheDocument(); + expect(mocked.listArchiveReviewMaterials).toHaveBeenCalledTimes(2); + }); }); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0c60df0..d7e498c 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,13 +9,17 @@ import { import { useCallback, useEffect, useMemo, useState } from "react"; import { deidentifyArchiveDraft, + diffArchiveReviewMaterials, getCase, + listArchiveReviewMaterials, listAdminArchiveDrafts, listCases, listCommandIntake, reviewArchiveDraft, + restoreArchiveReviewMaterial, } from "../api/cases"; import type { + ArchiveReviewMaterial, ArchiveDraft, CaseDetail, CaseListItem, @@ -362,12 +366,37 @@ function ArchiveReviewCard({ const [deidentifiedMaterial, setDeidentifiedMaterial] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [materials, setMaterials] = useState([]); + const [materialError, setMaterialError] = useState(""); + const [diff, setDiff] = useState<{ from: number; to: number; added: string[]; removed: string[] } | null>(null); + + const loadMaterials = useCallback(() => { + if (!token) return; + void listArchiveReviewMaterials(token, draft.id) + .then((items) => { + setMaterials(items); + setMaterialError(""); + }) + .catch((cause) => + setMaterialError( + cause instanceof Error ? cause.message : "无法加载审核材料版本。", + ), + ); + }, [draft.id, token]); + + useEffect(() => { + setDiff(null); + loadMaterials(); + }, [loadMaterials]); + const action = async (run: () => Promise) => { if (!token || !reason.trim()) return; setBusy(true); setError(""); try { onChanged(await run()); + setDiff(null); + loadMaterials(); setReason(""); } catch (cause) { setError(cause instanceof Error ? cause.message : "案例草稿审核失败。"); @@ -392,6 +421,73 @@ function ArchiveReviewCard({ 来源范围:{draft.source_scope.join("、")} · 脱敏: {draft.deidentification_status}

+
+
+ 审核材料版本 + {materials.length} 个版本 +
+ {materials.length > 0 && ( +
+ {materials.map((material) => ( +
+
+ v{material.version} · {material.status}{material.selected_for_ai ? " · 当前 AI 输入" : ""} + {material.status === "deidentified" && + !material.selected_for_ai && + matchesRestorableArchiveDraftStatus(draft.status) && ( + + )} +
+

{material.content}

+

Scope: {material.source_scope.join(", ")}

+ {materials.length > 1 && material.version !== materials[materials.length - 1].version && ( + + )} +
+ ))} +
+ )} + {diff && ( +
+ 差异:v{diff.from} 与 v{diff.to} + {diff.added.length > 0 &&

新增:{diff.added.join(" | ")}

} + {diff.removed.length > 0 &&

删除:{diff.removed.join(" | ")}

} + {diff.added.length === 0 && diff.removed.length === 0 &&

没有行级变更。

} +
+ )} + {materialError &&

{materialError}

} +
{draft.status !== "rejected" && draft.status !== "withdrawn" && ( <> ); } + +function matchesRestorableArchiveDraftStatus(status: ArchiveDraft["status"]): boolean { + return status === "pending_review"; +} diff --git a/src/api/models.rs b/src/api/models.rs index e0d1ada..2f95265 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -124,6 +124,37 @@ pub struct ReviewArchiveDraftRequest { pub reason: String, } +#[derive(Debug, Serialize)] +pub struct ArchiveReviewMaterialResponse { + pub id: String, + pub case_id: String, + pub version: i32, + pub parent_material_id: Option, + pub content: String, + pub source_scope: Vec, + pub status: String, + pub created_by_user_id: String, + pub reviewed_by_user_id: Option, + pub reviewed_at: Option, + pub review_reason: Option, + pub created_at: String, + pub selected_for_ai: bool, +} + +#[derive(Debug, Serialize)] +pub struct ArchiveReviewMaterialDiffResponse { + pub from_version: i32, + pub to_version: i32, + pub added: Vec, + pub removed: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestoreArchiveReviewMaterialRequest { + pub reason: String, +} + #[derive(Debug, Serialize)] pub struct AdminAuditEventResponse { pub id: String, diff --git a/src/api/routes/admin.rs b/src/api/routes/admin.rs index 4b5185a..8ddc64c 100644 --- a/src/api/routes/admin.rs +++ b/src/api/routes/admin.rs @@ -27,6 +27,18 @@ pub fn configure(config: &mut web::ServiceConfig) { .route( "/archive-drafts/{draft_id}/review", web::patch().to(review_archive_draft), + ) + .route( + "/archive-drafts/{draft_id}/review-materials", + web::get().to(list_archive_review_materials), + ) + .route( + "/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}", + web::get().to(diff_archive_review_materials), + ) + .route( + "/archive-drafts/{draft_id}/review-materials/{version}/restore", + web::post().to(restore_archive_review_material), ), ); } @@ -106,3 +118,54 @@ async fn review_archive_draft( .await?, )) } + +async fn list_archive_review_materials( + auth: AuthenticatedUser, + state: web::Data, + draft_id: web::Path, +) -> Result { + Ok(HttpResponse::Ok().json( + crate::services::case_collaboration_service::list_archive_review_materials( + &state.db, &auth, &draft_id, + ) + .await?, + )) +} + +async fn diff_archive_review_materials( + auth: AuthenticatedUser, + state: web::Data, + path: web::Path<(String, i32, i32)>, +) -> Result { + let (draft_id, from_version, to_version) = path.into_inner(); + Ok(HttpResponse::Ok().json( + crate::services::case_collaboration_service::diff_archive_review_materials( + &state.db, + &auth, + &draft_id, + from_version, + to_version, + ) + .await?, + )) +} + +async fn restore_archive_review_material( + auth: AuthenticatedUser, + state: web::Data, + path: web::Path<(String, i32)>, + request: web::Json, +) -> Result { + let (draft_id, version) = path.into_inner(); + Ok(HttpResponse::Ok().json( + crate::services::case_collaboration_service::restore_archive_review_material( + &state.db, + &auth, + &draft_id, + version, + request.into_inner(), + &state.ai_gateway, + ) + .await?, + )) +} diff --git a/src/application/services/case_collaboration_service.rs b/src/application/services/case_collaboration_service.rs index 7fb252c..c8ca1df 100644 --- a/src/application/services/case_collaboration_service.rs +++ b/src/application/services/case_collaboration_service.rs @@ -15,13 +15,14 @@ use crate::{ }, error::ApiError, models::{ - ArchiveDraftResponse, AuthenticatedUser, CasePoiItem, CasePoiQuery, CasePoiResponse, - CasePublicProgressItem, CasePublicProgressResponse, CaseSourceRecordResponse, - ClueDraftCandidate, ClueDraftFieldDecision, ClueDraftResponse, - CreateCaseSourceRecordRequest, CreateClueDraftRequest, CreateClueRequest, - CreateSummaryDraftRequest, DeidentifyArchiveDraftRequest, ReviewArchiveDraftRequest, - ReviewClueDraftRequest, ReviewSummaryDraftRequest, SummaryDraftDiffResponse, - SummaryDraftResponse, SummaryDraftVersionResponse, + ArchiveDraftResponse, ArchiveReviewMaterialDiffResponse, ArchiveReviewMaterialResponse, + AuthenticatedUser, CasePoiItem, CasePoiQuery, CasePoiResponse, CasePublicProgressItem, + CasePublicProgressResponse, CaseSourceRecordResponse, ClueDraftCandidate, + ClueDraftFieldDecision, ClueDraftResponse, CreateCaseSourceRecordRequest, + CreateClueDraftRequest, CreateClueRequest, CreateSummaryDraftRequest, + DeidentifyArchiveDraftRequest, RestoreArchiveReviewMaterialRequest, + ReviewArchiveDraftRequest, ReviewClueDraftRequest, ReviewSummaryDraftRequest, + SummaryDraftDiffResponse, SummaryDraftResponse, SummaryDraftVersionResponse, }, roles::{CaseRole, GlobalCapability}, services::{case_service, case_summary_service, task_service}, @@ -261,6 +262,12 @@ pub async fn deidentify_archive_draft( )); } let reason = required_text("reason", request.reason, 1_000)?; + let deidentified_material = if outcome == "confirm" { + Some(request.deidentified_material.unwrap_or_default()) + } else { + None + }; + let timestamp = now(); let transaction = db.begin().await?; let existing = archive_drafts::Entity::find_by_id(draft_id) .one(&transaction) @@ -271,17 +278,6 @@ pub async fn deidentify_archive_draft( "archive draft is not awaiting de-identification review".to_owned(), )); } - let timestamp = now(); - let next_deidentification_status = if outcome == "confirm" { - "deidentified" - } else { - "rejected" - }; - let next_status = if outcome == "confirm" { - "pending_review" - } else { - "rejected" - }; let material_id = existing.review_material_id.clone().ok_or_else(|| { ApiError::Conflict("archive draft is missing its review material".to_owned()) })?; @@ -289,83 +285,88 @@ pub async fn deidentify_archive_draft( .one(&transaction) .await? .ok_or(ApiError::Internal)?; - let ( - content, - provider_model, - template_version, - _material_status, - next_material_id, - execution_audit, - ) = if outcome == "confirm" { - let material_text = required_text( - "deidentified_material", - request.deidentified_material.unwrap_or_default(), - 12_000, - )?; - let material = archive_review_materials::ActiveModel { - id: Set(case_service::new_id()), - case_id: Set(original_material.case_id.clone()), - version: Set(original_material.version + 1), - parent_material_id: Set(Some(original_material.id)), - content: Set(material_text.clone()), - source_scope_json: Set(original_material.source_scope_json.clone()), - status: Set("deidentified".to_owned()), - created_by_user_id: Set(auth.id.clone()), - reviewed_by_user_id: Set(Some(auth.id.clone())), - reviewed_at: Set(Some(timestamp.clone())), - review_reason: Set(Some(reason.clone())), - created_at: Set(timestamp.clone()), - } - .insert(&transaction) - .await?; - let ai_request = AiRequest { capability: AiCapability::CaseOrganization, data_level: DataLevel::Internal, purpose: AiPurpose::CaseArchiveDraft, data_region: "CN".to_owned(), system_instruction: Some("Return JSON only: {timeline:string[],lessons:string[],uncertainty:string}. Use only this administrator-approved de-identified material. Do not infer identities, exact locations, health details, causes, or operational outcomes.".to_owned()), output_schema: Some(archive_candidate_schema()), output_schema_name: Some("case_archive_candidate".to_owned()), input: serde_json::to_string(&json!({"deidentified_material": material_text})).map_err(|_| ApiError::Internal)?, requested_output_tokens: 500, template_version: "case-archive-ai-v2".to_owned(), input_scope_reference: format!("approved-deidentified-material:{}:v{}", material.id, material.version), redaction_policy_version: "archive-approved-material-v1".to_owned() }; - let execution = gateway.execute(&ai_request).await; - let execution_audits = crate::ai_gateway::execution_attempt_audits(&ai_request, &execution); - let (content, provider_model, _audit_status) = match execution { - AiExecutionResult::Completed { route, output, .. } => { - match gateway.decode_json::(&output) { - Ok(candidate) if valid_archive_candidate(&candidate) => ( - archive_candidate_content(candidate), - Some(route.model), - AiTaskStatus::Completed, - ), - _ => ( - deterministic_archive_material_content(&material_text), - None, - AiTaskStatus::Failed, - ), - } + let deidentified_material = deidentified_material + .map(|material| required_text("deidentified_material", material, 12_000)) + .transpose()?; + let approved_material = if let Some(material_text) = deidentified_material.as_ref() { + Some( + archive_review_materials::ActiveModel { + id: Set(case_service::new_id()), + case_id: Set(original_material.case_id.clone()), + version: Set(original_material.version + 1), + parent_material_id: Set(Some(original_material.id.clone())), + content: Set(material_text.clone()), + source_scope_json: Set(original_material.source_scope_json.clone()), + status: Set("deidentified".to_owned()), + created_by_user_id: Set(auth.id.clone()), + reviewed_by_user_id: Set(Some(auth.id.clone())), + reviewed_at: Set(Some(timestamp.clone())), + review_reason: Set(Some(reason.clone())), + created_at: Set(timestamp.clone()), } - AiExecutionResult::Degraded { .. } => ( - deterministic_archive_material_content(&material_text), + .insert(&transaction) + .await?, + ) + } else { + None + }; + transaction.commit().await?; + + let (content, provider_model, template_version, next_material_id, execution_audits) = + if let Some(material) = approved_material.as_ref() { + let ai_request = AiRequest { capability: AiCapability::CaseOrganization, data_level: DataLevel::Internal, purpose: AiPurpose::CaseArchiveDraft, data_region: "CN".to_owned(), system_instruction: Some("Return JSON only: {timeline:string[],lessons:string[],uncertainty:string}. Use only this administrator-approved de-identified material. Do not infer identities, exact locations, health details, causes, or operational outcomes.".to_owned()), output_schema: Some(archive_candidate_schema()), output_schema_name: Some("case_archive_candidate".to_owned()), input: serde_json::to_string(&json!({"deidentified_material": material.content})).map_err(|_| ApiError::Internal)?, requested_output_tokens: 500, template_version: "case-archive-ai-v2".to_owned(), input_scope_reference: format!("approved-deidentified-material:{}:v{}", material.id, material.version), redaction_policy_version: "archive-approved-material-v1".to_owned() }; + let execution = gateway.execute(&ai_request).await; + let execution_audits = + crate::ai_gateway::execution_attempt_audits(&ai_request, &execution); + let (content, provider_model) = match execution { + AiExecutionResult::Completed { route, output, .. } => { + match gateway.decode_json::(&output) { + Ok(candidate) if valid_archive_candidate(&candidate) => { + (archive_candidate_content(candidate), Some(route.model)) + } + _ => ( + deterministic_archive_material_content(&material.content), + None, + ), + } + } + AiExecutionResult::Degraded { .. } => ( + deterministic_archive_material_content(&material.content), + None, + ), + AiExecutionResult::Failed { .. } => ( + deterministic_archive_material_content(&material.content), + None, + ), + }; + ( + content, + provider_model, + "case-archive-ai-v2".to_owned(), + Some(material.id.clone()), + Some(execution_audits), + ) + } else { + ( + existing.content.clone(), None, - AiTaskStatus::Degraded, - ), - AiExecutionResult::Failed { .. } => ( - deterministic_archive_material_content(&material_text), + existing.template_version.clone(), + Some(original_material.id.clone()), None, - AiTaskStatus::Failed, - ), + ) }; - ( - content, - provider_model, - "case-archive-ai-v2".to_owned(), - "deidentified", - Some(material.id), - Some(execution_audits), - ) + let next_deidentification_status = if outcome == "confirm" { + "deidentified" } else { - ( - existing.content.clone(), - None, - existing.template_version.clone(), - "rejected", - Some(original_material.id), - None, - ) + "rejected" + }; + let next_status = if outcome == "confirm" { + "pending_review" + } else { + "rejected" }; let next_version = existing.version.checked_add(1).ok_or(ApiError::Internal)?; + let transaction = db.begin().await?; let update = archive_drafts::Entity::update_many() .col_expr(archive_drafts::Column::Status, Expr::value(next_status)) .col_expr( @@ -416,7 +417,7 @@ pub async fn deidentify_archive_draft( .one(&transaction) .await? .ok_or_else(|| ApiError::Internal)?; - if let Some(execution_audits) = execution_audit { + if let Some(execution_audits) = execution_audits { crate::ai_gateway::persist_execution_audits( &transaction, &execution_audits, @@ -575,6 +576,304 @@ pub async fn list_archive_drafts_for_admin( .collect() } +/// Lists the immutable review-material chain for one archive draft, including +/// the administrator-approved material currently selected as AI input. +pub async fn list_archive_review_materials( + db: &DatabaseConnection, + auth: &AuthenticatedUser, + draft_id: &str, +) -> Result, ApiError> { + require_admin(auth)?; + let draft = archive_drafts::Entity::find_by_id(draft_id) + .one(db) + .await? + .ok_or_else(|| ApiError::NotFound("archive draft was not found".to_owned()))?; + archive_review_materials::Entity::find() + .filter(archive_review_materials::Column::CaseId.eq(&draft.case_id)) + .order_by_desc(archive_review_materials::Column::Version) + .all(db) + .await? + .into_iter() + .map(|material| { + archive_review_material_response(material, draft.review_material_id.as_deref()) + }) + .collect() +} + +/// Compares two immutable material versions without collapsing duplicate lines +/// or modifying either version. +pub async fn diff_archive_review_materials( + db: &DatabaseConnection, + auth: &AuthenticatedUser, + draft_id: &str, + from_version: i32, + to_version: i32, +) -> Result { + require_admin(auth)?; + if from_version < 1 || to_version < 1 { + return Err(ApiError::Validation( + "material versions must be positive".to_owned(), + )); + } + let draft = archive_drafts::Entity::find_by_id(draft_id) + .one(db) + .await? + .ok_or_else(|| ApiError::NotFound("archive draft was not found".to_owned()))?; + let materials = archive_review_materials::Entity::find() + .filter(archive_review_materials::Column::CaseId.eq(&draft.case_id)) + .filter(archive_review_materials::Column::Version.is_in([from_version, to_version])) + .all(db) + .await?; + let from = materials.iter().find(|item| item.version == from_version); + let to = materials.iter().find(|item| item.version == to_version); + let (Some(from), Some(to)) = (from, to) else { + return Err(ApiError::NotFound( + "archive review material version was not found".to_owned(), + )); + }; + let (added, removed) = ordered_line_diff(&from.content, &to.content); + Ok(ArchiveReviewMaterialDiffResponse { + from_version, + to_version, + added, + removed, + }) +} + +/// Copies a historical approved material into a new version and regenerates an +/// archive draft only while that draft is awaiting final administrator review. +pub async fn restore_archive_review_material( + db: &DatabaseConnection, + auth: &AuthenticatedUser, + draft_id: &str, + version: i32, + request: RestoreArchiveReviewMaterialRequest, + gateway: &crate::ai_gateway::AiGateway, +) -> Result { + require_admin(auth)?; + if version < 1 { + return Err(ApiError::Validation( + "material version must be positive".to_owned(), + )); + } + let reason = required_text("reason", request.reason, 1_000)?; + let transaction = db.begin().await?; + let existing = archive_drafts::Entity::find_by_id(draft_id) + .one(&transaction) + .await? + .ok_or_else(|| ApiError::NotFound("archive draft was not found".to_owned()))?; + if existing.status != "pending_review" { + return Err(ApiError::Conflict( + "archive draft is not awaiting final review and cannot restore material".to_owned(), + )); + } + let source = archive_review_materials::Entity::find() + .filter(archive_review_materials::Column::CaseId.eq(&existing.case_id)) + .filter(archive_review_materials::Column::Version.eq(version)) + .one(&transaction) + .await? + .ok_or_else(|| { + ApiError::NotFound("archive review material version was not found".to_owned()) + })?; + if source.status != "deidentified" { + return Err(ApiError::Conflict( + "only an administrator-approved deidentified material can be restored".to_owned(), + )); + } + let current_material_id = existing.review_material_id.clone().ok_or_else(|| { + ApiError::Conflict("archive draft is missing its selected review material".to_owned()) + })?; + let mut cursor = Some(current_material_id); + let mut visited = std::collections::HashSet::new(); + let mut source_is_in_chain = false; + while let Some(material_id) = cursor { + if !visited.insert(material_id.clone()) { + break; + } + if material_id == source.id { + source_is_in_chain = true; + break; + } + cursor = archive_review_materials::Entity::find_by_id(material_id) + .one(&transaction) + .await? + .and_then(|material| material.parent_material_id); + } + if !source_is_in_chain { + return Err(ApiError::Conflict( + "material version is not part of this archive draft's immutable review chain" + .to_owned(), + )); + } + let next_material_version = archive_review_materials::Entity::find() + .filter(archive_review_materials::Column::CaseId.eq(&existing.case_id)) + .order_by_desc(archive_review_materials::Column::Version) + .one(&transaction) + .await? + .map_or(Ok(1), |item| { + item.version.checked_add(1).ok_or(ApiError::Internal) + })?; + let timestamp = now(); + let restored = archive_review_materials::ActiveModel { + id: Set(case_service::new_id()), + case_id: Set(existing.case_id.clone()), + version: Set(next_material_version), + parent_material_id: Set(Some(source.id.clone())), + content: Set(source.content.clone()), + source_scope_json: Set(source.source_scope_json.clone()), + status: Set("deidentified".to_owned()), + created_by_user_id: Set(auth.id.clone()), + reviewed_by_user_id: Set(Some(auth.id.clone())), + reviewed_at: Set(Some(timestamp.clone())), + review_reason: Set(Some(reason.clone())), + created_at: Set(timestamp.clone()), + } + .insert(&transaction) + .await?; + transaction.commit().await?; + + let ai_request = AiRequest { + capability: AiCapability::CaseOrganization, + data_level: DataLevel::Internal, + purpose: AiPurpose::CaseArchiveDraft, + data_region: "CN".to_owned(), + system_instruction: Some("Return JSON only: {timeline:string[],lessons:string[],uncertainty:string}. Use only this administrator-approved de-identified material. Do not infer identities, exact locations, health details, causes, or operational outcomes.".to_owned()), + output_schema: Some(archive_candidate_schema()), + output_schema_name: Some("case_archive_candidate".to_owned()), + input: serde_json::to_string(&json!({"deidentified_material": restored.content})) + .map_err(|_| ApiError::Internal)?, + requested_output_tokens: 500, + template_version: "case-archive-ai-v2".to_owned(), + input_scope_reference: format!("approved-deidentified-material:{}:v{}", restored.id, restored.version), + redaction_policy_version: "archive-approved-material-v1".to_owned(), + }; + let execution = gateway.execute(&ai_request).await; + let execution_audits = crate::ai_gateway::execution_attempt_audits(&ai_request, &execution); + let (content, provider_model) = match execution { + AiExecutionResult::Completed { route, output, .. } => { + match gateway.decode_json::(&output) { + Ok(candidate) if valid_archive_candidate(&candidate) => { + (archive_candidate_content(candidate), Some(route.model)) + } + _ => ( + deterministic_archive_material_content(&restored.content), + None, + ), + } + } + _ => ( + deterministic_archive_material_content(&restored.content), + None, + ), + }; + let next_draft_version = existing.version.checked_add(1).ok_or(ApiError::Internal)?; + let transaction = db.begin().await?; + let updated = archive_drafts::Entity::update_many() + .col_expr( + archive_drafts::Column::Status, + Expr::value("pending_review"), + ) + .col_expr( + archive_drafts::Column::DeidentificationStatus, + Expr::value("deidentified"), + ) + .col_expr( + archive_drafts::Column::DeidentifiedByUserId, + Expr::value(Some(auth.id.clone())), + ) + .col_expr( + archive_drafts::Column::DeidentifiedAt, + Expr::value(Some(timestamp.clone())), + ) + .col_expr( + archive_drafts::Column::DeidentificationReason, + Expr::value(Some(reason.clone())), + ) + .col_expr(archive_drafts::Column::Content, Expr::value(content)) + .col_expr( + archive_drafts::Column::ProviderModel, + Expr::value(provider_model), + ) + .col_expr( + archive_drafts::Column::TemplateVersion, + Expr::value("case-archive-ai-v2"), + ) + .col_expr( + archive_drafts::Column::ReviewMaterialId, + Expr::value(Some(restored.id.clone())), + ) + .col_expr( + archive_drafts::Column::Version, + Expr::value(next_draft_version), + ) + .col_expr( + archive_drafts::Column::UsageScope, + Expr::value("internal_archive"), + ) + .col_expr( + archive_drafts::Column::RetentionStatus, + Expr::value("retained"), + ) + .col_expr( + archive_drafts::Column::ReviewedByUserId, + Expr::value(None::), + ) + .col_expr( + archive_drafts::Column::ReviewedAt, + Expr::value(None::), + ) + .col_expr( + archive_drafts::Column::ReviewReason, + Expr::value(None::), + ) + .col_expr( + archive_drafts::Column::UpdatedAt, + Expr::value(timestamp.clone()), + ) + .filter(archive_drafts::Column::Id.eq(draft_id)) + .filter(archive_drafts::Column::Version.eq(existing.version)) + .filter(archive_drafts::Column::Status.eq(&existing.status)) + .filter( + archive_drafts::Column::DeidentificationStatus.eq(&existing.deidentification_status), + ) + .exec(&transaction) + .await?; + if updated.rows_affected != 1 { + return Err(ApiError::Conflict( + "archive draft changed before material restore could be recorded".to_owned(), + )); + } + let model = archive_drafts::Entity::find_by_id(draft_id) + .one(&transaction) + .await? + .ok_or(ApiError::Internal)?; + crate::ai_gateway::persist_execution_audits( + &transaction, + &execution_audits, + &auth.id, + Some(&model.case_id), + ) + .await?; + case_service::write_audit( + &transaction, + Some(model.case_id.clone()), + auth, + "archive_review_material.restored", + "archive_review_material", + restored.id, + Some(json!({ + "draft_id": draft_id, + "source_version": version, + "restored_version": next_material_version, + "draft_version": model.version, + "reason_length": reason.chars().count(), + })), + ) + .await?; + transaction.commit().await?; + archive_draft_response(model) +} + pub async fn get_public_progress( db: &DatabaseConnection, auth: &AuthenticatedUser, @@ -1288,6 +1587,38 @@ fn deterministic_archive_material_content(material: &str) -> String { } } +// Retain every occurrence and ordering signal while avoiding an unbounded +// quadratic diff for administrator-supplied material up to the input limit. +fn ordered_line_diff(from: &str, to: &str) -> (Vec, Vec) { + let from_lines = from.lines().collect::>(); + let to_lines = to.lines().collect::>(); + let mut prefix = 0; + while prefix < from_lines.len() + && prefix < to_lines.len() + && from_lines[prefix] == to_lines[prefix] + { + prefix += 1; + } + + let mut from_end = from_lines.len(); + let mut to_end = to_lines.len(); + while from_end > prefix && to_end > prefix && from_lines[from_end - 1] == to_lines[to_end - 1] { + from_end -= 1; + to_end -= 1; + } + + ( + to_lines[prefix..to_end] + .iter() + .map(|line| (*line).to_owned()) + .collect(), + from_lines[prefix..from_end] + .iter() + .map(|line| (*line).to_owned()) + .collect(), + ) +} + fn valid_archive_candidate(candidate: &ArchiveOrganizationCandidate) -> bool { candidate.timeline.len() <= 12 && candidate.lessons.len() <= 12 @@ -1379,6 +1710,29 @@ fn archive_draft_response(model: archive_drafts::Model) -> Result, +) -> Result { + let selected_for_ai = selected_id == Some(model.id.as_str()); + Ok(ArchiveReviewMaterialResponse { + id: model.id, + case_id: model.case_id, + version: model.version, + parent_material_id: model.parent_material_id, + content: model.content, + source_scope: serde_json::from_str(&model.source_scope_json) + .map_err(|_| ApiError::Internal)?, + status: model.status, + created_by_user_id: model.created_by_user_id, + reviewed_by_user_id: model.reviewed_by_user_id, + reviewed_at: model.reviewed_at, + review_reason: model.review_reason, + created_at: model.created_at, + selected_for_ai, + }) +} + fn require_admin(auth: &AuthenticatedUser) -> Result<(), ApiError> { auth.global_capabilities .contains(&GlobalCapability::Admin) diff --git a/src/config.rs b/src/config.rs index 881b7c8..5efb168 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,9 @@ use std::{ path::{Component, PathBuf}, }; -use crate::integrations::ai_gateway::{ProviderConfig, validate_provider_configurations}; +use crate::integrations::ai_gateway::{ + AiGateway, ProviderConfig, validate_provider_configurations, +}; /// Load a local development `.env` file without overriding process-level /// configuration. A missing file is normal in production and CI. @@ -37,25 +39,25 @@ pub struct Settings { impl Settings { pub fn from_env() -> Result { load_local_env_file()?; - Self::from_values(|name| env::var(name).ok()) + Self::from_values(environment_value) } /// Parses settings from a supplied lookup so validation can be tested without /// mutating process-global environment variables. fn from_values(mut value: F) -> Result where - F: FnMut(&str) -> Option, + F: FnMut(&str) -> Result, String>, { - let host = value("ANGUI_HOST").unwrap_or_else(|| "127.0.0.1".to_owned()); - let port_value = value("ANGUI_PORT").unwrap_or_else(|| "8080".to_owned()); + let host = value("ANGUI_HOST")?.unwrap_or_else(|| "127.0.0.1".to_owned()); + let port_value = value("ANGUI_PORT")?.unwrap_or_else(|| "8080".to_owned()); let port = port_value .parse::() .map_err(|_| format!("ANGUI_PORT must be a valid TCP port, got {port_value:?}"))?; let frontend_origin = - value("ANGUI_FRONTEND_ORIGIN").unwrap_or_else(|| "http://localhost:5173".to_owned()); + value("ANGUI_FRONTEND_ORIGIN")?.unwrap_or_else(|| "http://localhost:5173".to_owned()); let database_url = - value("DATABASE_URL").unwrap_or_else(|| "sqlite://data/angui.db?mode=rwc".to_owned()); - let session_ttl_value = value("ANGUI_SESSION_TTL_HOURS").unwrap_or_else(|| "8".to_owned()); + value("DATABASE_URL")?.unwrap_or_else(|| "sqlite://data/angui.db?mode=rwc".to_owned()); + let session_ttl_value = value("ANGUI_SESSION_TTL_HOURS")?.unwrap_or_else(|| "8".to_owned()); let session_ttl_hours = session_ttl_value.parse::().map_err(|_| { format!("ANGUI_SESSION_TTL_HOURS must be a positive integer, got {session_ttl_value:?}") })?; @@ -63,7 +65,7 @@ impl Settings { return Err("ANGUI_SESSION_TTL_HOURS must be between 1 and 168".to_owned()); } let intake_answer_hard_max_value = - value("ANGUI_INTAKE_ANSWER_HARD_MAX").unwrap_or_else(|| "2000".to_owned()); + value("ANGUI_INTAKE_ANSWER_HARD_MAX")?.unwrap_or_else(|| "2000".to_owned()); let intake_answer_hard_max = intake_answer_hard_max_value.parse::().map_err(|_| { format!( "ANGUI_INTAKE_ANSWER_HARD_MAX must be a positive integer, got {intake_answer_hard_max_value:?}" @@ -73,7 +75,7 @@ impl Settings { return Err("ANGUI_INTAKE_ANSWER_HARD_MAX must be between 1 and 10000".to_owned()); } let attachment_storage_directory = PathBuf::from( - value("ANGUI_ATTACHMENT_STORAGE_DIRECTORY") + value("ANGUI_ATTACHMENT_STORAGE_DIRECTORY")? .unwrap_or_else(|| "data/attachments".to_owned()), ); if attachment_storage_directory.as_os_str().is_empty() { @@ -89,48 +91,39 @@ impl Settings { ); } let attachment_max_image_bytes = parse_bounded_usize( - value("ANGUI_ATTACHMENT_MAX_IMAGE_BYTES") + value("ANGUI_ATTACHMENT_MAX_IMAGE_BYTES")? .unwrap_or_else(|| (5 * 1024 * 1024).to_string()), "ANGUI_ATTACHMENT_MAX_IMAGE_BYTES", 1024, 20 * 1024 * 1024, )?; let attachment_max_per_case = parse_bounded_u64( - value("ANGUI_ATTACHMENT_MAX_PER_CASE").unwrap_or_else(|| "12".to_owned()), + value("ANGUI_ATTACHMENT_MAX_PER_CASE")?.unwrap_or_else(|| "12".to_owned()), "ANGUI_ATTACHMENT_MAX_PER_CASE", 1, 100, )?; let case_place_types = - parse_case_place_types(&value("ANGUI_CASE_PLACE_TYPES").unwrap_or_else(|| { + parse_case_place_types(&value("ANGUI_CASE_PLACE_TYPES")?.unwrap_or_else(|| { "frequent,key_location,last_seen_context,medical,shelter,other".to_owned() }))?; let amap_webservice_key = - value("AMAP_WEBSERVICE_KEY").filter(|value| !value.trim().is_empty()); - let amap_webservice_base_url = value("AMAP_WEBSERVICE_BASE_URL") + value("AMAP_WEBSERVICE_KEY")?.filter(|value| !value.trim().is_empty()); + let amap_webservice_base_url = value("AMAP_WEBSERVICE_BASE_URL")? .unwrap_or_else(|| "https://restapi.amap.com".to_owned()); if !amap_webservice_base_url.starts_with("https://") { return Err("AMAP_WEBSERVICE_BASE_URL must use https".to_owned()); } - let amap_timeout_value = value("AMAP_TIMEOUT_MS").unwrap_or_else(|| "2500".to_owned()); + let amap_timeout_value = value("AMAP_TIMEOUT_MS")?.unwrap_or_else(|| "2500".to_owned()); let amap_timeout_ms = amap_timeout_value.parse::().map_err(|_| { format!("AMAP_TIMEOUT_MS must be a positive integer, got {amap_timeout_value:?}") })?; if !(100..=10_000).contains(&amap_timeout_ms) { return Err("AMAP_TIMEOUT_MS must be between 100 and 10000".to_owned()); } - let provider_configurations_value = - value("ANGUI_AI_PROVIDERS_JSON").unwrap_or_else(|| "[]".to_owned()); - let ai_provider_configurations: Vec = serde_json::from_str( - &provider_configurations_value, - ) - .map_err(|error| { - format!( - "ANGUI_AI_PROVIDERS_JSON must be a JSON array of provider configurations: {error}" - ) - })?; - validate_provider_configurations(&ai_provider_configurations) - .map_err(|error| error.to_string())?; + let ai_provider_configurations = parse_ai_provider_configurations( + value("ANGUI_AI_PROVIDERS_JSON")?.unwrap_or_else(|| "[]".to_owned()), + )?; Ok(Self { host, @@ -155,6 +148,36 @@ impl Settings { } } +/// Validate the AI provider policy without initializing any other runtime +/// components. Preview deployment uses this before replacing an existing +/// environment so malformed policy cannot take the old preview down first. +pub fn validate_ai_provider_configurations_from_env() -> Result<(), String> { + load_local_env_file()?; + let value = environment_value("ANGUI_AI_PROVIDERS_JSON")?.unwrap_or_else(|| "[]".to_owned()); + let configurations = parse_ai_provider_configurations(value)?; + let gateway = + AiGateway::from_configurations(configurations).map_err(|error| error.to_string())?; + gateway + .validate_runtime_configuration() + .map_err(|error| error.to_string()) +} + +fn environment_value(name: &str) -> Result, String> { + match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => Err(format!("{name} must contain valid UTF-8 text")), + } +} + +fn parse_ai_provider_configurations(value: String) -> Result, String> { + let configurations: Vec = serde_json::from_str(&value).map_err(|error| { + format!("ANGUI_AI_PROVIDERS_JSON must be a JSON array of provider configurations: {error}") + })?; + validate_provider_configurations(&configurations).map_err(|error| error.to_string())?; + Ok(configurations) +} + fn parse_bounded_usize( value: String, name: &str, @@ -216,7 +239,7 @@ mod tests { .iter() .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) .collect(); - Settings::from_values(|name| values.get(name).cloned()) + Settings::from_values(|name| Ok(values.get(name).cloned())) } #[test] @@ -302,6 +325,20 @@ mod tests { assert_eq!(settings.amap_webservice_key, None); } + #[test] + fn environment_lookup_errors_are_not_treated_as_missing_values() { + let error = Settings::from_values(|name| { + if name == "ANGUI_HOST" { + Err("ANGUI_HOST must contain valid UTF-8 text".to_owned()) + } else { + Ok(None) + } + }) + .expect_err("environment lookup failures must be propagated"); + + assert_eq!(error, "ANGUI_HOST must contain valid UTF-8 text"); + } + #[test] fn numeric_configuration_rejects_malformed_and_out_of_range_values() { for (name, value, expected_error) in [ diff --git a/src/integrations/ai_gateway.rs b/src/integrations/ai_gateway.rs index fd71dfa..43b11fa 100644 --- a/src/integrations/ai_gateway.rs +++ b/src/integrations/ai_gateway.rs @@ -592,6 +592,41 @@ impl AiGateway { Ok(Self { providers }) } + /// Validate provider endpoint and credential references without sending a + /// request. This uses the same native request materialization path as + /// execution, including endpoint URI and non-empty credential checks. + pub fn validate_runtime_configuration(&self) -> Result<(), AiGatewayError> { + let request = AiRequest { + capability: AiCapability::Inquiry, + data_level: DataLevel::Public, + purpose: AiPurpose::IntakeDraft, + data_region: "runtime-validation".to_owned(), + system_instruction: None, + output_schema: None, + output_schema_name: None, + input: "runtime configuration validation".to_owned(), + requested_output_tokens: 1, + template_version: "runtime-validation-v1".to_owned(), + input_scope_reference: "runtime-validation".to_owned(), + redaction_policy_version: "runtime-validation-v1".to_owned(), + }; + + for registered in &self.providers { + let route = registered.provider.route(); + let endpoint = env::var(&route.endpoint_env).map_err(|_| { + AiGatewayError::MissingRuntimeConfiguration(route.endpoint_env.clone()) + })?; + let credential = env::var(&route.credential_env).map_err(|_| { + AiGatewayError::MissingRuntimeConfiguration(route.credential_env.clone()) + })?; + registered + .provider + .build_request(&request)? + .into_native_request(&endpoint, &credential)?; + } + Ok(()) + } + pub fn route(&self, request: &AiRequest) -> GatewayDecision { self.eligible_routes(request) .into_iter() @@ -1142,6 +1177,53 @@ mod tests { (endpoint, calls) } + #[tokio::test] + async fn runtime_configuration_validation_requires_usable_references() { + let _environment = ENV_LOCK.lock().await; + unsafe { + env::remove_var("ANGUI_TEST_AI_ENDPOINT"); + env::remove_var("ANGUI_TEST_AI_KEY"); + } + let gateway = AiGateway::from_configurations(vec![provider( + "runtime-validation", + ProviderProtocol::OpenAiResponses, + )]) + .expect("fixture is valid"); + + assert!(matches!( + gateway.validate_runtime_configuration(), + Err(AiGatewayError::MissingRuntimeConfiguration(name)) if name == "ANGUI_TEST_AI_ENDPOINT" + )); + + unsafe { + env::set_var("ANGUI_TEST_AI_ENDPOINT", "https://ai.example.invalid"); + env::set_var("ANGUI_TEST_AI_KEY", ""); + } + assert!(matches!( + gateway.validate_runtime_configuration(), + Err(AiGatewayError::InvalidNativeRequest(_)) + )); + + unsafe { + env::set_var("ANGUI_TEST_AI_ENDPOINT", "https://invalid endpoint"); + env::set_var("ANGUI_TEST_AI_KEY", "test-key"); + } + assert!(matches!( + gateway.validate_runtime_configuration(), + Err(AiGatewayError::InvalidNativeRequest(_)) + )); + + unsafe { + env::set_var("ANGUI_TEST_AI_ENDPOINT", "https://ai.example.invalid"); + } + assert!(gateway.validate_runtime_configuration().is_ok()); + + unsafe { + env::remove_var("ANGUI_TEST_AI_ENDPOINT"); + env::remove_var("ANGUI_TEST_AI_KEY"); + } + } + #[tokio::test] async fn invalid_json_runs_one_repair_request_and_stops_after_two_calls() { let _environment = ENV_LOCK.lock().await; diff --git a/src/main.rs b/src/main.rs index 197649a..25b7f0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -use std::io; +use std::{env, io}; use actix_cors::Cors; use actix_web::{App, HttpServer, http, middleware::Logger, web}; use angui::{ api::{rate_limit::LoginRateLimiter, routes}, application::{app_state::AppState, services::task_service}, - config::Settings, + config::{Settings, validate_ai_provider_configurations_from_env}, integrations::{ai_gateway::AiGateway, amap_service::AmapService}, }; use sea_orm::Database; @@ -14,6 +14,11 @@ use sea_orm::Database; async fn main() -> io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + if env::args().nth(1).as_deref() == Some("validate-ai-config") { + return validate_ai_provider_configurations_from_env() + .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message)); + } + let settings = Settings::from_env() .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?; let address = settings.address(); diff --git a/tests/api/archive_drafts_review.rs b/tests/api/archive_drafts_review.rs index c38c168..26fa1f4 100644 --- a/tests/api/archive_drafts_review.rs +++ b/tests/api/archive_drafts_review.rs @@ -134,6 +134,19 @@ async fn archive_deidentification_and_review_require_admin_and_preserve_auditabl "the review reason must not be returned in the archive response" ); + let restore_published = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/2/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": "cannot restore published draft" })) + .to_request(), + ) + .await; + assert_error(restore_published, StatusCode::CONFLICT, "conflict").await; + let withdrawn = test::call_service( &app, test::TestRequest::patch() @@ -152,6 +165,19 @@ async fn archive_deidentification_and_review_require_admin_and_preserve_auditabl assert_eq!(withdrawn["retention_status"], "withdrawn"); assert_eq!(withdrawn["version"], 4); + let restore_withdrawn = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/2/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": "cannot restore withdrawn draft" })) + .to_request(), + ) + .await; + assert_error(restore_withdrawn, StatusCode::CONFLICT, "conflict").await; + let stored = archive_drafts::Entity::find_by_id(&draft_id) .one(&context.database) .await @@ -272,6 +298,19 @@ async fn archive_deidentification_rejection_and_missing_drafts_do_not_publish_or assert_eq!(review_rejected["usage_scope"], "internal_archive"); assert_eq!(review_rejected["retention_status"], "retained"); assert_eq!(review_rejected["version"], 3); + + let restore_rejected = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{review_rejection_id}/review-materials/2/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": "cannot restore rejected draft" })) + .to_request(), + ) + .await; + assert_error(restore_rejected, StatusCode::CONFLICT, "conflict").await; assert!( !review_rejected .to_string() @@ -296,3 +335,324 @@ async fn archive_deidentification_rejection_and_missing_drafts_do_not_publish_or .await; assert_error(missing, StatusCode::NOT_FOUND, "not_found").await; } + +#[actix_web::test] +async fn archive_review_material_versions_support_admin_list_diff_restore_and_rbac() { + let context = TestContext::new().await; + let app = crate::init_api_app!(&context); + let commander_token = context.token(COMMANDER).await; + let admin_token = context.token(ADMIN).await; + let family_token = context.token(FAMILY).await; + let (case_id, draft_id) = create_finished_archive_draft!(&context, &app, &commander_token); + + let family_list = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {family_token}"))) + .to_request(), + ) + .await; + assert_error(family_list, StatusCode::FORBIDDEN, "forbidden").await; + + let family_diff = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/diff/1/2" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {family_token}"))) + .to_request(), + ) + .await; + assert_error(family_diff, StatusCode::FORBIDDEN, "forbidden").await; + + let family_restore = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/1/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {family_token}"))) + .set_json(json!({ "reason": "unauthorized restore attempt" })) + .to_request(), + ) + .await; + assert_error(family_restore, StatusCode::FORBIDDEN, "forbidden").await; + + let initial = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .to_request(), + ) + .await; + assert_eq!(initial.status(), StatusCode::OK); + let initial: Vec = test::read_body_json(initial).await; + assert_eq!(initial.len(), 1); + assert_eq!(initial[0]["version"], 1); + assert_eq!(initial[0]["status"], "draft"); + assert_eq!(initial[0]["selected_for_ai"], true); + assert_eq!( + initial[0]["source_scope"][0], + "confirmed_clue_review_material" + ); + + let deidentified = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!("/api/admin/archive-drafts/{draft_id}/deidentify")) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ + "outcome": "confirm", + "reason": "version test", + "deidentified_material": "line one\nline one\nline two" + })) + .to_request(), + ) + .await; + assert_eq!(deidentified.status(), StatusCode::OK); + + let invalid_restore_version = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/0/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": "invalid version" })) + .to_request(), + ) + .await; + assert_error( + invalid_restore_version, + StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + + let blank_restore_reason = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/2/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": " " })) + .to_request(), + ) + .await; + assert_error( + blank_restore_reason, + StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + + let restore_unapproved_material = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/1/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": "draft material is not approved" })) + .to_request(), + ) + .await; + assert_error( + restore_unapproved_material, + StatusCode::CONFLICT, + "conflict", + ) + .await; + + let missing_restore_material = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/99/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": "missing material" })) + .to_request(), + ) + .await; + assert_error(missing_restore_material, StatusCode::NOT_FOUND, "not_found").await; + + let missing_diff_material = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/diff/2/99" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .to_request(), + ) + .await; + assert_error(missing_diff_material, StatusCode::NOT_FOUND, "not_found").await; + + let versions = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .to_request(), + ) + .await; + let versions: Vec = test::read_body_json(versions).await; + assert_eq!(versions.len(), 2); + assert_eq!(versions[0]["version"], 2); + assert_eq!(versions[0]["status"], "deidentified"); + assert_eq!(versions[0]["selected_for_ai"], true); + assert_eq!(versions[1]["selected_for_ai"], false); + + let diff = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/diff/1/2" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .to_request(), + ) + .await; + assert_eq!(diff.status(), StatusCode::OK); + let diff: Value = test::read_body_json(diff).await; + assert_eq!(diff["from_version"], 1); + assert_eq!(diff["to_version"], 2); + assert_eq!(diff["added"], json!(["line one", "line one", "line two"])); + + let restored = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials/2/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({"reason": "restore approved version for correction"})) + .to_request(), + ) + .await; + assert_eq!(restored.status(), StatusCode::OK); + let restored: Value = test::read_body_json(restored).await; + assert_eq!(restored["status"], "pending_review"); + assert_eq!(restored["version"], 3); + let selected_id = restored["review_material_id"] + .as_str() + .expect("restored draft should select a material"); + + let versions_after_restore = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!( + "/api/admin/archive-drafts/{draft_id}/review-materials" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .to_request(), + ) + .await; + let versions_after_restore: Vec = test::read_body_json(versions_after_restore).await; + assert_eq!(versions_after_restore.len(), 3); + assert_eq!(versions_after_restore[0]["version"], 3); + assert_eq!(versions_after_restore[0]["status"], "deidentified"); + assert_eq!(versions_after_restore[0]["selected_for_ai"], true); + assert_eq!( + versions_after_restore[0]["parent_material_id"], + versions_after_restore[1]["id"] + ); + assert_eq!(versions_after_restore[0]["id"], selected_id); + assert_eq!( + versions_after_restore[1]["content"], + "line one\nline one\nline two" + ); + + let audit = audit_events::Entity::find() + .filter(audit_events::Column::EntityType.eq("archive_review_material")) + .filter(audit_events::Column::EntityId.eq(selected_id)) + .one(&context.database) + .await + .expect("material audit query should succeed") + .expect("restore should be audited"); + assert_eq!(audit.action, "archive_review_material.restored"); + assert!(!audit.metadata_json.unwrap_or_default().contains("line one")); + assert_eq!(case_id, restored["case_id"]); +} + +#[actix_web::test] +async fn archive_material_restore_rejects_deidentified_versions_outside_the_draft_chain() { + let context = TestContext::new().await; + let app = crate::init_api_app!(&context); + let commander_token = context.token(COMMANDER).await; + let admin_token = context.token(ADMIN).await; + let (case_id, first_draft_id) = + create_finished_archive_draft!(&context, &app, &commander_token); + + let first_deidentified = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{first_draft_id}/deidentify" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ + "outcome": "confirm", + "reason": "first material confirmation", + "deidentified_material": "first approved material" + })) + .to_request(), + ) + .await; + assert_eq!(first_deidentified.status(), StatusCode::OK); + + let second_created = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!("/api/cases/{case_id}/archive-drafts")) + .insert_header((header::AUTHORIZATION, format!("Bearer {commander_token}"))) + .to_request(), + ) + .await; + assert_eq!(second_created.status(), StatusCode::CREATED); + let second_created: Value = test::read_body_json(second_created).await; + let second_draft_id = second_created["id"] + .as_str() + .expect("second archive draft id should be returned"); + + let second_deidentified = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{second_draft_id}/deidentify" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ + "outcome": "confirm", + "reason": "second material confirmation", + "deidentified_material": "second approved material" + })) + .to_request(), + ) + .await; + assert_eq!(second_deidentified.status(), StatusCode::OK); + + let cross_chain_restore = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/admin/archive-drafts/{second_draft_id}/review-materials/2/restore" + )) + .insert_header((header::AUTHORIZATION, format!("Bearer {admin_token}"))) + .set_json(json!({ "reason": "must not restore another draft material" })) + .to_request(), + ) + .await; + assert_error(cross_chain_restore, StatusCode::CONFLICT, "conflict").await; +} diff --git a/tests/api/openapi_intake_contract.rs b/tests/api/openapi_intake_contract.rs index 6f20eac..a050342 100644 --- a/tests/api/openapi_intake_contract.rs +++ b/tests/api/openapi_intake_contract.rs @@ -424,21 +424,47 @@ fn archive_review_openapi_contract_requires_admin_and_manual_deidentification() "#/components/schemas/ArchiveDraft", "#/components/schemas/ReviewArchiveDraftRequest", ), + ( + "/api/admin/archive-drafts/{draft_id}/review-materials:\n", + "operationId: listArchiveReviewMaterials", + "#/components/schemas/ArchiveReviewMaterial", + "", + ), + ( + "/api/admin/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}:\n", + "operationId: diffArchiveReviewMaterials", + "#/components/schemas/ArchiveReviewMaterialDiff", + "", + ), + ( + "/api/admin/archive-drafts/{draft_id}/review-materials/{version}/restore:\n", + "operationId: restoreArchiveReviewMaterial", + "#/components/schemas/ArchiveDraft", + "#/components/schemas/RestoreArchiveReviewMaterialRequest", + ), ] { let operation = operation(path); - for expected in [ + let mut expected_items = vec![ operation_id, "x-global-capabilities: [admin]", "x-data-classification: restricted-admin", schema_name, - request_schema, - ] { + ]; + if !request_schema.is_empty() { + expected_items.push(request_schema); + } + for expected in expected_items { assert!( operation.contains(expected), "OpenAPI operation {path} must declare {expected:?}" ); } } + assert!( + operation("/api/admin/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}:\n") + .contains("\"400\": { $ref: \"#/components/responses/ValidationError\" }"), + "the material diff contract must document version validation failures" + ); assert_schema_contains( "DeidentifyArchiveDraftRequest", &[