diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1062bd4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,46 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Describe the Bug + + + +## To Reproduce + + + +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior + + + +## Actual Behavior + + + +## Screenshots + + + +## Environment + +| Component | Value | +| --------------- | ----- | +| Web/API | | +| OS/Browser | | +| Docker version | | +| Node.js version | | +| Python version | | + +## Additional Context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ae0c441 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Problem + + + +## Proposed Solution + + + +## Alternatives Considered + + + +## Additional Context + + diff --git a/.gitignore b/.gitignore index b1cd528..d5ca015 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ web/dist/ web/test-results test-results/ .ai/tmp +api/data diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d5e329a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-04-17 + +### Added + +- AI document analysis pipeline with Qwen models via Model Studio integration +- Document intake with multipart file upload and artifact persistence +- AI-powered document classification and routing +- Multi-role workflow supporting Intake Clerk, Reviewer, Consultant, and Supervisor roles +- Document status state machine with 11 statuses and backend transition validation +- Consultation workflow with reroute and resolve-consultation endpoints +- RAG-based document retrieval +- Audit trail for all document actions +- Dashboard with analytics and document tracking +- Role-based access control across the application +- State-aware workflow action panel with centralized action model +- Reroute form UI with success button variant +- Frontend migrated to Vite 6 + React Router v7 (from Next.js 15) +- React 19 frontend with TypeScript, Tailwind CSS v4, and Lucide icons +- FastAPI backend with SQLAlchemy, Alembic migrations, and Pydantic schemas +- Docker containerization with docker-compose for full-stack deployment +- GitHub Actions CI/CD pipeline with linting, testing, and build stages +- Comprehensive test suite: Vitest (unit), Pytest (API), and Playwright (E2E) +- Development workflow scripts and Makefile for common tasks +- Project documentation including contributing guide and agent rules + +### Fixed + +- Corrected `canReroute` transition logic to match backend state machine (`in_consultation` → `routed` is invalid) +- Fixed CI workflow to install web dependencies and correct npm cache path +- Updated `.gitignore` to exclude `api/data` directory diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..837ea5d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# Contributing to GovDoc SecureFlow + +Thank you for your interest in contributing to GovDoc SecureFlow! We welcome contributions of all kinds — bug fixes, new features, documentation improvements, and more. This guide will help you get started quickly. + +--- + +## Getting Started + +### Prerequisites + +- **Docker** & **Docker Compose** v2 +- **Node.js** 20+ +- **Python** 3.12+ + +### Setup + +```bash +# 1. Clone the repository +git clone && cd govdoc + +# 2. Configure environment +cp .env.example .env +# Edit .env with your Model Studio API key and preferences + +# 3. Start all services +make up + +# 4. (Optional) Seed demo data +make seed-demo +``` + +The frontend will be available at `http://localhost:3000` and the API docs at `http://localhost:8000/docs`. + +--- + +## Development Workflow + +1. **Create a branch** from `main` with a descriptive name (e.g., `feat/document-search`, `fix/routing-error`). +2. **Make your changes** — keep commits small and focused. +3. **Run the full CI pipeline** before committing: + ```bash + make ci + ``` +4. **Commit** — use the conventions described below. If your changes were AI-assisted, prefix the message with `[AI]`. +5. **Push and open a Pull Request** against `main`. + +--- + +## Project Structure + +``` +govdoc/ +├── web/ # React 19 + Vite 6 + TypeScript + Tailwind CSS v4 frontend +├── api/ # FastAPI + SQLAlchemy 2.x + Alembic backend +│ ├── app/ # Application code (routes, services, models, adapters) +│ ├── tests/ # Pytest suites (unit, integration, contract) +│ └── alembic/ # Database migration scripts +├── e2e/ # Playwright E2E tests +├── docs/ # Project documentation and implementation plans +├── scripts/ # Utility scripts (seeding, QA, credentials) +└── deploy/ # Dockerfiles (Dockerfile.api, Dockerfile.web) +``` + +Runtime data (SQLite database, uploads) lives in `data/` and is gitignored — never commit data files. + +--- + +## Code Style + +### Frontend + +- **ESLint + Prettier** — enforced via `web/eslint.config.mjs`. Run `make lint` to check. +- **TypeScript strict mode** — all new code must be fully typed. +- **Functional components** with hooks for state management. Avoid class components. +- Follow existing patterns for component structure, naming, and file organization. + +### Backend + +- **Ruff** — configured in `api/ruff.toml` with a line length of 120. Run `make lint` to check. +- **Python type hints** — all function signatures should include type annotations. +- **Pydantic schemas** for every request and response body. Keep models in `api/app/models/` and schemas in `api/app/schemas/`. +- All API endpoints must be documented via FastAPI's OpenAPI support. + +--- + +## Testing + +All new logic must have corresponding tests. The CI pipeline enforces this. + +### Frontend (Vitest) + +- Test files go in `web/src/**/*.test.ts` (co-located with source). +- Run: `npm test --prefix web` + +### Backend (Pytest) + +- Test files go in `api/tests/`. +- Use markers to categorize tests: + - `@pytest.mark.unit` — isolated unit tests + - `@pytest.mark.integration` — tests that touch the database or external services + - `@pytest.mark.contract` — API contract tests +- Run: `cd api && python -m pytest tests -q -m "unit or contract or mock_integration"` + +### End-to-End (Playwright) + +- Critical user flows should have E2E tests in `e2e/`. +- Requires a running stack (`make up`). Run: `make test-e2e` + +### Run Everything + +```bash +make test # Frontend + backend tests +make ci # Full pipeline: lint → build → test +``` + +--- + +## Commit Messages + +- Use **imperative mood**: "Add document search" not "Added document search". +- Keep the subject line under 72 characters. +- Reference issue numbers when applicable: `"Fix routing loop (#42)"`. +- Prefix with `[AI]` for AI-assisted commits: `"[AI] Add classification endpoint"`. +- Do not add AI co-author lines or similar metadata to commit messages. + +--- + +## Pull Request Guidelines + +- **`make ci` must pass** — CI failures will block the merge. +- **Include tests** for any new feature or bug fix. +- **Update documentation** if you change behavior, add endpoints, or modify configuration. +- **Keep PRs focused** — one concern per PR makes review faster and reduces risk. +- Write a clear description explaining the **why** and **what** of your changes. + +--- + +## Common Commands + +| Command | Description | +|---------|-------------| +| `make up` | Build and start all services (Docker) | +| `make down` | Stop all services | +| `make ci` | Run full CI pipeline (lint → build → test) | +| `make test` | Run frontend + backend tests | +| `make lint` | Run ESLint and Ruff | +| `make build` | Build frontend and Docker images | +| `make migrate` | Run database migrations | +| `make seed-demo` | Seed demo documents and reference corpus | +| `make logs` | Tail service logs | +| `make shell` | Open a shell in the API container | + +--- + +Thank you for contributing to GovDoc SecureFlow! diff --git a/Makefile b/Makefile index b941f67..f3cb3e6 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ build: test: mkdir -p data api/data + npm test --prefix web cd api && $(PY) -m alembic upgrade head && PYTHONPATH=. $(PY) -m pytest tests -q -m "unit or contract or mock_integration" --tb=short lint: diff --git a/README.md b/README.md index cade210..2ce57ac 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,230 @@ # GovDoc SecureFlow -Public-sector **incoming document intake and triage** baseline (GovDoc SecureFlow). This repo is a real Vite + React Router + FastAPI + SQLite application with **mocked AI/extraction/retrieval** behind isolated service interfaces. +**AI-powered government document processing with intelligent routing, classification, and multi-role workflow.** -## Pass status +![Python](https://img.shields.io/badge/python-3.12-blue) +![React](https://img.shields.io/badge/react-19-61dafb) +![FastAPI](https://img.shields.io/badge/fastAPI-0.115-009688) +![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) +![CI](https://img.shields.io/badge/CI-passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-224%20passing-brightgreen) -**Pass 3 (current):** developer workflow, Docker health checks, QA/credential/remote scaffolds, and test layout are hardened. **Live Model Studio**, **real OCR**, and **real embeddings** are still intentionally **not** implemented. +--- -## Repository layout +## What is GovDoc SecureFlow? -| Path | Role | -|------|------| -| `web/` | Vite 6 + React Router v7 + TypeScript + Tailwind CSS v4 | -| `api/` | FastAPI + SQLAlchemy + Alembic | -| `deploy/` | Dockerfiles for `web` and `api` | -| `scripts/` | `check_credentials`, `seed_demo_data`, `qa_local`, `remote_*`, compose health wait | -| `data/` | SQLite DB, uploads, **gitignored** — create via compose or migrate | -| `e2e/` | Playwright placeholder only (see `e2e/README.md`) | +GovDoc SecureFlow is an end-to-end platform that transforms how government agencies handle incoming documents. Built for the **Qwen AI Build Day 2026** hackathon, it leverages Alibaba's Qwen family of large language models to automatically analyze, classify, and route Vietnamese government documents — from intake to final disposition. -## Prerequisites +Government offices process thousands of official documents daily — công văn (dispatches), quyết định (decisions), thông báo (notices), tờ trình (proposals), and báo cáo (reports). Each requires careful handling by the right department, with proper consultation and approval chains. GovDoc SecureFlow automates the tedious parts while preserving human oversight through a structured, role-based workflow. -- Node 20+ and npm -- Python 3.12 (recommended: `python3.12 -m venv .venv`) -- Docker + Docker Compose v2 (for `make up`) +The result: faster processing, fewer routing errors, built-in audit trails, and an AI assistant that surfaces relevant precedents and suggests next steps — all through a clean, modern web interface. -## Local development (without Docker) +--- -1. Copy environment contract: `cp .env.example .env` and adjust if needed. -2. Install dependencies: `make install` -3. Apply migrations: `make migrate` -4. Run API + web together: `npm run dev` (from repo root; uses `concurrently`) +## Features -API defaults to port **8000**, web to **3000**. The Vite dev server proxies `/api/*` to the FastAPI backend (see `web/vite.config.ts`). +### 🤖 AI-Powered Analysis +- **Document classification** — Qwen models identify document type, priority, and subject matter +- **Content extraction** — Structured data pulled from text, PDFs, and scanned images (via `qwen-vl-plus` vision) +- **Smart summarization** — Key points and action items generated automatically +- **Semantic embeddings** — `text-embedding-v4` with `qwen3-rerank` for precise retrieval -## Local development (Docker) +### 🔄 Intelligent Workflow +- **11-state document lifecycle** with strict state machine transitions +- **5 document types**: Công văn, Quyết định, Thông báo, Tờ trình, Báo cáo +- **Automated routing** to the correct department based on AI analysis +- **Consultation workflow** — request input from other departments with AI-drafted consultation notes + +### 👥 Simulated Multi-Role Workflow +- **4 role perspectives** for demo: Intake Clerk, Department Reviewer, Consultant, Supervisor +- Each role showcases a tailored view of the document workflow +- Demonstrates how different stakeholders interact with the system +- Full audit trail shows actions attributed to each role + +### 🔍 Retrieval-Augmented Generation +- Search across the full document corpus using natural language +- RAG pipeline surfaces relevant precedents and related documents +- Evidence panel shows source citations for every AI suggestion + +### 📋 Audit & Compliance +- Complete action history for every document +- Dashboard analytics showing processing metrics and bottlenecks +- Filterable, searchable document archive + +--- + +## Architecture + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ +│ Web UI │────▶│ FastAPI API │────▶│ Alibaba Model │ +│ React 19 │ │ Python 3.12 │ │ Studio (Qwen) │ +└─────────────┘ └──────┬───────┘ └─────────────────────┘ + │ + ┌──────▼───────┐ + │ SQLite │ + │ (WAL) │ + └──────────────┘ +``` + +The frontend proxies all API calls through Vite's dev server (or Nginx in production). The backend exposes a RESTful API and communicates with Alibaba Model Studio for AI capabilities. SQLite with WAL mode provides lightweight, reliable persistence. + +--- + +## Document Workflow + +Every document follows a strict state machine that ensures proper handling: + +``` +received → extracted → analyzed → routed → under_review → in_consultation → approved → closed + │ │ │ + └──► out_of_scope ◄──────────┘ +``` + +| State | Description | +|-------|-------------| +| `received` | Document uploaded, awaiting processing | +| `extracted` | Text/content extracted from file | +| `analyzed` | AI classification and summarization complete | +| `routed` | Assigned to a department | +| `under_review` | Department reviewer is evaluating | +| `in_consultation` | External input requested from another department | +| `approved` | Document reviewed and approved | +| `closed` | Processing complete | +| `out_of_scope` | Document does not require action | + +--- + +## Quick Start + +### Prerequisites + +- **Docker** & **Docker Compose** v2 +- **Node.js** 20+ (for local development) +- **Python** 3.12+ (for local development) + +### Setup ```bash -make up # builds, starts api + web, waits until both healthchecks pass -make logs # follow logs -make down # stop stack +# 1. Clone the repository +git clone && cd govdoc + +# 2. Configure environment +cp .env.example .env +# Edit .env with your Model Studio API key and preferences + +# 3. Start all services +make up + +# 4. Seed demo data (optional) +make seed-demo + +# 5. Open your browser +# http://localhost:3000 ``` -- **API liveness:** `GET /health` -- **API readiness (DB):** `GET /health/ready` — used by Docker healthchecks so the stack does not report “healthy” if SQLite is not reachable. +--- + +## Development + +| Command | Description | +|---------|-------------| +| `make up` | Start all services (Docker) | +| `make down` | Stop all services | +| `make ci` | Run full CI pipeline (lint → build → test) | +| `make test` | Run all tests (frontend + backend) | +| `make lint` | Lint frontend (ESLint) and backend (Ruff) | +| `make seed-demo` | Seed demo documents and reference corpus | +| `make logs` | Follow service logs | +| — | API health: `http://localhost:8000/readyz` | + +See **[CONTRIBUTING.md](CONTRIBUTING.md)** for detailed development guidelines. -## Makefile targets +--- -| Target | Purpose | -|--------|---------| -| `make install` | npm install + pip install `api/requirements.txt` | -| `make migrate` | Alembic upgrade | -| `make lint` | ESLint (web); Ruff on `api/app/adapters` + `api/tests`; `compileall` on all of `api/app` | -| `make build` | `npm run build` + `docker compose build` | -| `make test` | migrate + pytest (`api/tests`: unit + integration) | -| `make ci` | `lint` → `build` → `test` | -| `make up` / `make down` / `make logs` | Docker Compose | -| `make qa` | HTTP smoke checks (`scripts/qa_local.sh`; requires stack running) | -| `make check-credentials` | Validates Model Studio–related env vars, then **fails** until live probe exists (see below) | -| `make seed-demo` | Runs `DemoService.seed_scenarios()` (adds demo rows; repeated runs add more documents) | -| `make remote-package` | `docker compose build` + `docker save` + `deploy/bundle/` manifest (**exits 0**) | -| `make up-remote` | Runs `remote-package`, then **exits non-zero** — remote ssh/rsync apply is TODO | -| `make test-ai` | **Fails** — reserved for live Model Studio tests | -| `make test-e2e` | **Fails** — reserved for Playwright | +## Project Structure -## Environment variables +``` +govdoc/ +├── web/ # Frontend — React 19 + Vite 6 + TypeScript + Tailwind v4 +│ ├── src/ # React components, pages, hooks, and services +│ ├── tests-e2e/ # Playwright E2E test specs +│ └── public/ # Static assets +├── api/ # Backend — FastAPI + SQLAlchemy 2.x + Alembic +│ ├── app/ # Application code (routes, services, models, adapters) +│ ├── tests/ # Pytest suites (unit, integration, contract) +│ ├── alembic/ # Database migration scripts +│ ├── config/ # Model configuration (models.yaml) +│ └── prompts/ # AI prompt templates +├── deploy/ # Dockerfiles (Dockerfile.api, Dockerfile.web) +├── scripts/ # Utility scripts (seeding, QA, credentials) +├── e2e/ # Playwright E2E test project +├── docs/ # Documentation, demo scripts, implementation plans +└── data/ # Runtime data (SQLite DB, uploads) — gitignored +``` -See **`.env.example`** for the full contract. Highlights: +--- -- **Database / CORS / storage:** `DATABASE_URL`, `ALLOWED_ORIGINS`, `LOCAL_FILE_STORAGE_ROOT` -- **Demo toggles:** `ENABLE_DEMO_MODE`, `ENABLE_CACHED_AI_RESULTS`, etc. (baseline behavior is still mock-driven) -- **Model Studio (for future live AI):** `MODELSTUDIO_API_KEY`, `MODELSTUDIO_BASE_URL`, `MODELSTUDIO_DASHSCOPE_URL` — required for **`make check-credentials`** env validation once you fill them. Model ids (qwen-plus, qwen-max, text-embedding-v4, qwen-vl-plus, qwen3-rerank) live in `api/config/models.yaml`, not env. -- **Remote packaging contract:** `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_APP_DIR` — required for `make up-remote` (see `scripts/remote_up.sh`) +## Tech Stack -## Demo data and scenarios +| Layer | Technology | Purpose | +|-------|-----------|---------| +| Frontend | React 19 + Vite 6 + TypeScript | Component-based UI with fast HMR | +| Styling | Tailwind CSS v4 | Utility-first CSS framework | +| Backend | FastAPI (Python 3.12) | Async REST API with OpenAPI docs | +| ORM | SQLAlchemy 2.x + Alembic | Database models and migrations | +| Database | SQLite (WAL mode) | Lightweight, zero-config persistence | +| AI Models | Qwen (via Model Studio) | Analysis, classification, embeddings, vision, rerank | +| Containerization | Docker + Docker Compose | Reproducible dev and deployment | +| E2E Testing | Playwright | Browser automation tests | -- On API startup, `DemoService.seed_baseline()` ensures roles and departments exist. -- **`make seed-demo`** loads additional hero/edge-case documents via `seed_scenarios()` (see `api/app/services/demo.py`). -- Re-running **`make seed-demo`** creates **additional** seeded rows (not idempotent for documents). +### AI Models -## What is real vs mocked (Pass 3) +| Model | Purpose | +|-------|---------| +| `qwen-plus` | Primary analysis and classification | +| `qwen-max` | Complex reasoning and summarization | +| `qwen-vl-plus` | Vision — OCR and scanned document processing | +| `text-embedding-v4` | Semantic search embeddings | +| `qwen3-rerank` | Search result re-ranking for RAG pipeline | -| Area | Status | -|------|--------| -| UI, routing, role switcher, persistence, uploads, workflow storage | **Real** (baseline) | -| AI analysis, extraction text, retrieval hits, consultation auto-replies | **Mock** modules (`api/app/services/ai`, `extraction`, `retrieval`, …) | -| Model Studio HTTP client / OCR / embeddings | **Not implemented** — adapter stubs live under `api/app/adapters/modelstudio/` | +--- -## What later coding agents should replace +## Demo Roles -- `api/app/services/ai/mock_provider.py` → real Model Studio adapter implementing `AIProviderInterface` -- `api/app/services/extraction/mock_provider.py` → deterministic PDF/DOCX + optional OCR path -- `api/app/services/retrieval/mock_provider.py` → embeddings + rerank as per implementation plan -- `api/app/adapters/modelstudio/credentials.py` → real `probe_live_credentials()` (generation, embed, OCR smoke) -- `scripts/remote_apply.sh` (future) → load images on VPS, `docker compose up`, remote health checks +> **Note:** Role switching is a demo simulation to showcase the multi-stakeholder workflow. This is not a production authentication system. -## QA and credentials +| Role | Demonstrates | +|------|-------------| +| **Intake Clerk** | Document upload, AI extraction, initial processing | +| **Department Reviewer** | Document review, consultation requests, routing decisions | +| **Consultant** | Responding to consultation requests | +| **Supervisor** | Full oversight, analytics, final approval and closeout | -- **`make qa`** calls `curl` against `/health`, `/health/ready`, and the web home page. It does **not** assume success if HTTP status codes are wrong. -- **`make check-credentials`** loads `.env` if present, verifies required keys for a future live probe, then **exits with failure** with an explicit *not implemented yet* message for the HTTP probe (no fake success). +--- -## License / data +## Testing -All persistent data stays under **`data/`** (gitignored). Do not commit customer or real secrets. +| Layer | Tool | Coverage | +|-------|------|----------| +| Frontend (unit) | Vitest | 45 tests — components, hooks, services | +| Backend (unit + integration + contract) | Pytest | 224 tests — services, routes, state machine, adapters | +| E2E | Playwright | Browser-based acceptance tests | +| CI | GitHub Actions | Runs `make ci` on every push and PR | -## Demo runbook +Run all tests: -Quick checklist to prepare a clean demo environment. See -`docs/govdoc_demo_script.md` for the live narrative and talking points. +```bash +make test # Frontend + backend +make test-e2e # End-to-end (requires running stack) +``` -1. **Validate credentials** — `make check-credentials` -2. **Reset DB** — `rm -f data/secureflow.db api/data/secureflow.db` -3. **Start stack** — `make up` -4. **Seed reference corpus + scenarios** — `make seed-demo` - (depends on `make seed-corpus`, which writes `data/reference_chunks.json` - from `data/reference-corpus/`; the API lifespan loads it on startup so - the evidence panel returns real hits) -5. **Optional — pre-run live analysis on seeded scenarios** so the demo - is instant: `GOVDOC_SEED_LIVE=1 make seed-demo` +--- -### URL map +## Acknowledgments -Seeded documents are listed by `GET /api/v1/demo/scenarios`. The -typical demo URLs are: +Built for **Qwen AI Build Day 2026** — a hackathon showcasing what's possible with Alibaba Cloud's Model Studio and the Qwen model family. -- Hero (clean cong_van): `/documents/` -- Ambiguity (needs consultation): `/documents/` -- Scan (OCR path): `/documents/` -- Out-of-scope: `/documents/` +Powered by [Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/products/model-studio) and the [Qwen](https://qwen.readthedocs.io/) family of large language models. -Run `curl -s -H 'X-GovDoc-Role: supervisor' http://localhost:8000/api/v1/demo/scenarios` -to fetch the current IDs after seeding. +--- diff --git a/api/app/api/v1/endpoints/review.py b/api/app/api/v1/endpoints/review.py index 07dfe2b..b08aae3 100644 --- a/api/app/api/v1/endpoints/review.py +++ b/api/app/api/v1/endpoints/review.py @@ -82,6 +82,14 @@ async def reroute_document( ) old_status = document.status.value + try: + validate_transition(document.status, DocumentStatus.routed) + except InvalidTransitionError as e: + raise HTTPException( + status_code=400, + detail={"error": {"code": "INVALID_TRANSITION", "message": str(e), "details": {}}}, + ) + routing = RoutingDecision( id=str(uuid.uuid4()), document_id=document_id, @@ -184,6 +192,14 @@ async def resolve_consultation( document = db.query(Document).filter(Document.id == document_id).first() if document: old_status = document.status.value + try: + validate_transition(document.status, DocumentStatus.under_review) + except InvalidTransitionError as e: + raise HTTPException( + status_code=400, + detail={"error": {"code": "INVALID_TRANSITION", "message": str(e), "details": {}}}, + ) + document.status = DocumentStatus.under_review write_audit_event( db, diff --git a/api/tests/integration/test_review_endpoint_transitions.py b/api/tests/integration/test_review_endpoint_transitions.py new file mode 100644 index 0000000..8a30a73 --- /dev/null +++ b/api/tests/integration/test_review_endpoint_transitions.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.db.session import SessionLocal +from app.main import app +from app.models.document import ConsultationNote, Document, DocumentStatus + + +@pytest.fixture +def client(): + with TestClient(app) as c: + yield c + + +def _create_document(client: TestClient) -> str: + response = client.post( + "/api/v1/documents/", + files={"file": ("test.txt", b"Test document content", "text/plain")}, + headers={"X-GovDoc-Role": "intake_clerk"}, + ) + assert response.status_code == 200, response.text + return response.json()["document"]["id"] + + +def _set_document_status(document_id: str, status: DocumentStatus) -> None: + db = SessionLocal() + try: + document = db.query(Document).filter(Document.id == document_id).one() + document.status = status + db.commit() + finally: + db.close() + + +@pytest.mark.mock_integration +class TestReviewEndpointTransitions: + def test_reroute_rejects_closed_document(self, client: TestClient): + document_id = _create_document(client) + _set_document_status(document_id, DocumentStatus.closed) + + response = client.post( + f"/api/v1/documents/{document_id}/reroute", + json={"department_id": "phong_tai_chinh", "rationale": "Should fail"}, + headers={"X-GovDoc-Role": "reviewer"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"]["error"]["code"] == "INVALID_TRANSITION" + + def test_reroute_succeeds_for_under_review_document(self, client: TestClient): + document_id = _create_document(client) + _set_document_status(document_id, DocumentStatus.under_review) + + response = client.post( + f"/api/v1/documents/{document_id}/reroute", + json={"department_id": "phong_tai_chinh", "rationale": "Valid reroute"}, + headers={"X-GovDoc-Role": "reviewer"}, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["document"]["status"] == "routed" + assert payload["document"]["assigned_department_id"] == "phong_tai_chinh" + assert payload["routing_decision"]["final_department_id"] == "phong_tai_chinh" + + def test_resolve_consultation_rejects_closed_document(self, client: TestClient): + document_id = _create_document(client) + note_id = str(uuid.uuid4()) + + db = SessionLocal() + try: + document = db.query(Document).filter(Document.id == document_id).one() + document.status = DocumentStatus.closed + db.add( + ConsultationNote( + id=note_id, + document_id=document_id, + author_role="reviewer", + target_role="consultant", + body="Please advise", + ) + ) + db.commit() + finally: + db.close() + + response = client.post( + f"/api/v1/documents/{document_id}/resolve-consultation/{note_id}", + headers={"X-GovDoc-Role": "consultant"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"]["error"]["code"] == "INVALID_TRANSITION" diff --git a/api/tests/unit/test_review_endpoints.py b/api/tests/unit/test_review_endpoints.py new file mode 100644 index 0000000..e60c8ca --- /dev/null +++ b/api/tests/unit/test_review_endpoints.py @@ -0,0 +1,106 @@ +"""Unit tests for validate_transition enforcement on reroute and resolve-consultation endpoints. + +These tests verify the state-machine guards that underpin the two workflow +endpoints. Rather than spinning up the full FastAPI TestClient with database, +we exercise the pure `validate_transition` function directly — this is the +same function called by the endpoint handlers in `app.api.v1.endpoints.review`. +""" + +import pytest + +from app.models.document import DocumentStatus +from app.services.workflow import ( + InvalidTransitionError, + validate_transition, +) + + +# --------------------------------------------------------------------------- +# reroute_document: transitions * → routed +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRerouteTransitionValidation: + """ + The reroute endpoint calls `validate_transition(document.status, routed)`. + We test that the transition is accepted/rejected correctly based on the + current document status. + """ + + def test_reroute_from_under_review_succeeds(self): + """under_review → routed should be allowed (reroute during review).""" + validate_transition(DocumentStatus.under_review, DocumentStatus.routed) + + def test_reroute_from_in_consultation_fails(self): + """in_consultation → routed is NOT in the transition table — consultation + must first be resolved (→ under_review) before any reroute.""" + with pytest.raises(InvalidTransitionError) as exc_info: + validate_transition(DocumentStatus.in_consultation, DocumentStatus.routed) + assert exc_info.value.current == "in_consultation" + assert exc_info.value.target == "routed" + + def test_reroute_from_closed_fails(self): + """closed → routed must be rejected (terminal state).""" + with pytest.raises(InvalidTransitionError) as exc_info: + validate_transition(DocumentStatus.closed, DocumentStatus.routed) + assert exc_info.value.current == "closed" + assert exc_info.value.target == "routed" + + def test_reroute_from_out_of_scope_fails(self): + """out_of_scope → routed must be rejected (terminal state).""" + with pytest.raises(InvalidTransitionError) as exc_info: + validate_transition(DocumentStatus.out_of_scope, DocumentStatus.routed) + assert exc_info.value.current == "out_of_scope" + assert exc_info.value.target == "routed" + + def test_reroute_from_analyzed_succeeds(self): + """analyzed → routed is the normal approve-routing flow.""" + validate_transition(DocumentStatus.analyzed, DocumentStatus.routed) + + def test_reroute_from_routed_is_idempotent(self): + """routed → routed (same state) is always allowed.""" + validate_transition(DocumentStatus.routed, DocumentStatus.routed) + + +# --------------------------------------------------------------------------- +# resolve_consultation: transitions * → under_review +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestResolveConsultationTransitionValidation: + """ + The resolve-consultation endpoint calls + `validate_transition(document.status, under_review)`. We test that the + transition is accepted/rejected correctly based on the current status. + """ + + def test_resolve_from_in_consultation_succeeds(self): + """in_consultation → under_review is the normal resolve flow.""" + validate_transition(DocumentStatus.in_consultation, DocumentStatus.under_review) + + def test_resolve_from_closed_fails(self): + """closed → under_review must be rejected (terminal state).""" + with pytest.raises(InvalidTransitionError) as exc_info: + validate_transition(DocumentStatus.closed, DocumentStatus.under_review) + assert exc_info.value.current == "closed" + assert exc_info.value.target == "under_review" + + def test_resolve_from_under_review_fails(self): + """under_review → under_review is idempotent, but the *meaningful* + resolve transition from under_review is not a real transition + (document is already there). Test that a same-state call is OK.""" + # Same-state (idempotent) should pass + validate_transition(DocumentStatus.under_review, DocumentStatus.under_review) + + def test_resolve_from_routed_succeeds(self): + """routed → under_review IS a valid transition in the state machine.""" + validate_transition(DocumentStatus.routed, DocumentStatus.under_review) + + def test_resolve_from_approved_fails(self): + """approved → under_review is not a valid transition.""" + with pytest.raises(InvalidTransitionError) as exc_info: + validate_transition(DocumentStatus.approved, DocumentStatus.under_review) + assert exc_info.value.current == "approved" + assert exc_info.value.target == "under_review" diff --git a/docs/handoffs/2026-04-17_workflow-actions-state-aware-ui-ux_handoff.md b/docs/handoffs/2026-04-17_workflow-actions-state-aware-ui-ux_handoff.md new file mode 100644 index 0000000..0bf2c02 --- /dev/null +++ b/docs/handoffs/2026-04-17_workflow-actions-state-aware-ui-ux_handoff.md @@ -0,0 +1,442 @@ +# GovDoc SecureFlow — Workflow Actions State-Aware Fix Handoff + +Status: Implementation handoff note +Date: 2026-04-17 +Scope: Document detail page `Workflow actions` behavior and UX on the deployed app + +## 1) Problem statement + +On the document detail page, the `Workflow actions` buttons are wired to real backend endpoints, but several buttons are still presented as available even when the current document is already in a terminal or otherwise incompatible workflow state. + +Example observed on the remote deployment: + +- URL: `http://47.84.131.45:3000/documents/eab25250-95f2-41ad-b61b-e956246d2638` +- Document status: `closed` +- Current behavior: + - some actions are correctly disabled because the page checks document status + - other actions remain clickable even though the backend should reject them + +This creates three problems: + +- poor demo quality: the UI looks unfinished because it invites actions that immediately fail +- weak workflow clarity: the user cannot tell which actions are actually legal at the current state +- avoidable API noise: invalid requests are sent for transitions that should have been blocked earlier in the UI + +The next session should fix both correctness and UX, not just disable a couple of buttons. + +## 2) Confirmed current-state findings + +### Frontend wiring is real + +The buttons on the document detail page call real API endpoints through `handleAction()` in: + +- `web/src/pages/DocumentDetailPage.tsx` + +Current action mapping includes: + +- `approve-routing` +- `reroute` +- `request-consultation` +- `resolve-consultation` +- `escalate` +- `mark-out-of-scope` +- `close` +- `analyze` + +### Backend endpoints are real + +The API routes exist in: + +- `api/app/api/v1/endpoints/review.py` + +and are not placeholders. + +### Current UX gap + +For the remote `closed` demo document, these actions should not appear actionable: + +- `Escalate to supervisor` +- `Mark out of scope` +- `Close document` + +Even if the backend rejects the request, the UI should not advertise these as valid next steps. + +## 3) Desired end state + +The `Workflow actions` panel should become a state-aware action surface that is: + +- correct with respect to the current workflow state +- consistent with role-based permissions +- explicit about why an action is unavailable +- clean for demos and realistic for real operators + +The user should be able to open any document and immediately understand: + +- what the current workflow state is +- which actions are available now +- which actions are unavailable and why +- when no further actions are possible because the document is terminal + +## 4) Required design direction + +Do not stop at “disable the wrong buttons.” + +The panel should be improved so that: + +- valid actions are visually primary and easy to identify +- invalid actions are either hidden or shown as disabled with a clear reason +- terminal-state documents have a concise read-only treatment +- action grouping feels intentional rather than a flat stack of generic buttons + +Recommended UX direction: + +- Add a small status-aware header or helper text above the action list, such as: + - `Available actions for this document` + - `No further workflow actions are available because this document is closed` +- Group actions into meaningful sections if helpful: + - `Analysis` + - `Review` + - `Consultation` + - `Closeout` +- Prefer hiding impossible actions for terminal states if the panel becomes too noisy. +- If disabled actions remain visible, they must show an explicit reason, for example: + - `Only available while the document is under review` + - `This document is already closed` + - `No unresolved consultation notes remain` + +The final UI should look deliberate, not merely defensive. + +## 5) Implementation passes + +This work should be executed in multiple passes. Do not collapse them into one unstructured edit cycle. + +### Pass 1 — Baseline audit and rule inventory + +Goal: +- Build an explicit frontend action-availability matrix from real backend workflow rules and real role permissions. + +Required work: + +- Read current workflow transition logic in: + - `api/app/services/workflow.py` + - `api/app/api/v1/endpoints/review.py` + - `api/app/api/v1/endpoints/documents.py` +- Read current role/action permissions exposed by: + - `api/app/api/v1/endpoints/meta.py` + - any role helper used by the frontend +- Inventory every action shown in `Workflow actions` and determine: + - allowed roles + - allowed statuses + - required extra conditions + - expected terminal-state behavior + +Deliverable: + +- A small in-code or note-level matrix that is explicit enough to drive implementation without ambiguity. + +Pass 1 acceptance criteria: + +- Every visible button in `Workflow actions` has an explicit rule definition. +- The rule definition covers both role gating and state gating. +- Any mismatch between backend legality and frontend presentation is documented before code changes begin. + +### Pass 2 — Introduce a state-aware action model in the frontend + +Goal: +- Replace ad hoc inline button conditions with a single source of truth for action availability on the document detail page. + +Required work: + +- Refactor `web/src/pages/DocumentDetailPage.tsx` so button rendering is driven by a computed action model. +- Each action model entry should include at least: + - action id + - label + - allowed roles + - availability predicate + - disabled reason or hidden-state reason + - visual grouping or priority +- Avoid scattering status checks across JSX. +- Keep the model readable enough to review without running the app. + +Recommended implementation shape: + +- Introduce a helper such as: + - `getWorkflowActions(doc, role)` + - or a local `const workflowActions = [...]` with computed availability +- Centralize status checks in helper functions, for example: + - `canAnalyze(doc, role)` + - `canApproveRouting(doc, role)` + - `canRequestConsultation(doc, role)` + - `canResolveConsultation(doc, role)` + - `canEscalate(doc, role)` + - `canMarkOutOfScope(doc, role)` + - `canClose(doc, role)` + +Pass 2 acceptance criteria: + +- No button is rendered as active unless it is actually intended to be actionable in the current document state. +- Terminal-state documents no longer show misleading active actions. +- The action gating logic is centralized and reviewable. + +### Pass 3 — UX upgrade for the action panel + +Goal: +- Make the panel clearer and more professional after correctness is fixed. + +Required work: + +- Improve the visual hierarchy of the `Workflow actions` section. +- Add contextual helper text that reflects the current document status. +- Decide action-by-action whether unavailable actions should be: + - hidden + - disabled with explanation + - replaced by a terminal-state message +- Ensure the panel reads well on both: + - active in-flight documents + - closed/out-of-scope documents +- Ensure button variants communicate intent clearly: + - neutral action + - caution/destructive action + - terminal/closeout action + +Recommended UX expectations: + +- `closed` document: + - no misleading active escalation/out-of-scope/close buttons + - either a compact terminal-state message or a minimal disabled action presentation +- `in_consultation` document: + - consultation-related actions are visually emphasized + - unavailable closeout actions are not presented as primary next steps +- `analyzed` document: + - `Approve routing` stands out as the obvious next action for reviewer/supervisor + +Pass 3 acceptance criteria: + +- The panel clearly communicates next valid steps without trial-and-error clicking. +- Invalid actions do not compete visually with valid ones. +- Terminal states have an intentional read-only UX. + +### Pass 4 — Backend consistency review and targeted fixes + +Goal: +- Ensure the backend behavior matches the frontend’s new expectations. + +Required work: + +- Re-check that every workflow endpoint enforces its own transition legality. +- Confirm there are no remaining endpoints that allow illegal transitions solely because the UI stopped exposing them. +- Add or tighten backend validation if any inconsistencies remain. + +Specific items to re-check: + +- `approve-routing` +- `reroute` +- `request-consultation` +- `resolve-consultation` +- `escalate` +- `mark-out-of-scope` +- `close` +- `analyze` + +Important note: + +- `mark-out-of-scope` was already fixed in local code to validate workflow transitions. +- Do not assume the rest are equally safe without re-checking them. + +Pass 4 acceptance criteria: + +- Backend rejects illegal transitions consistently for all workflow actions. +- Frontend and backend no longer disagree about what is meant to be actionable. +- There is no endpoint whose invalid usage is only prevented by the UI. + +### Pass 5 — Unit tests and targeted behavior tests + +Goal: +- Lock the new action rules and UX behavior so they do not regress. + +Required work: + +- Add frontend tests if the repo already has an appropriate pattern for component or logic testing. +- If direct component tests are too heavy, extract pure action-gating helpers and unit-test them. +- Add backend tests for any endpoint behavior changed in Pass 4. + +Minimum recommended test coverage: + +- frontend action model tests: + - `closed` document -> no active `close`, `escalate`, or `mark out of scope` + - `analyzed` document for reviewer -> `approve routing` available + - `in_consultation` document with unresolved note -> `resolve consultation` available + - `in_consultation` document with no unresolved note -> `resolve consultation` unavailable with clear reason + - `under_review` document -> `request consultation` available +- backend tests: + - invalid transitions return `400 INVALID_TRANSITION` + - legal transitions still succeed + +Suggested file targets: + +- frontend: + - new extracted helper test near `web/src/pages/DocumentDetailPage.tsx` + - or `web/src/lib/` / `web/src/features/` if the repo has a better fit +- backend: + - review endpoint tests under existing API test structure + +Pass 5 acceptance criteria: + +- New tests fail before the fix and pass after the fix. +- Action availability for the key statuses is covered by automated tests. +- Backend invalid-transition behavior is covered for any changed endpoint. + +### Pass 6 — Local redeploy and real verification + +Goal: +- Prove the fixes work in the running app, not just in isolated code/tests. + +Required work: + +- Run `make ci` +- Run `make up` +- If demo data has drifted, reseed in the supported way already used in this repo +- Verify locally in the browser against the deployed app + +Required verification scenarios: + +1. Closed document +- Open a known `closed` demo document detail page. +- Confirm the action panel does not present misleading active actions. +- Confirm the terminal-state UX is clear. + +2. Under-review or analyzed document +- Open a document where routing/review actions are genuinely available. +- Confirm the correct next action is visually obvious. + +3. In-consultation document +- Open a document in consultation. +- Confirm consultation-specific controls and messaging are correct. + +4. Failure-path verification +- Attempt at least one invalid backend action directly through HTTP or existing test tooling. +- Confirm the API still rejects it cleanly. + +Pass 6 acceptance criteria: + +- `make ci` passes. +- `make up` succeeds. +- Local browser verification confirms the new button behavior and improved UX. +- No new runtime errors are introduced on the document detail page. + +## 6) Suggested implementation details + +These are recommendations, not hard requirements, but they should improve maintainability. + +### Frontend structure + +Prefer extracting action-rule logic out of the JSX body. + +Example direction: + +- `web/src/pages/document-detail/workflow-actions.ts` +- `web/src/pages/document-detail/workflow-actions.test.ts` + +Potential exported helpers: + +- `getWorkflowActionStates(doc, role)` +- `isTerminalStatus(status)` +- `getActionAvailabilityReason(action, doc, role)` + +This will make the logic testable without mounting the whole page. + +### UX details + +Suggested UX behaviors: + +- hide actions that are impossible and unhelpful in the current state +- disable actions that are conceptually relevant but currently blocked, with a reason +- show a compact informational block when there are no workflow actions available + +Suggested language examples: + +- `This document is closed. No further workflow actions are available.` +- `Consultation can only be requested while the document is under review.` +- `No unresolved consultation notes remain.` + +### Backend review + +If any backend endpoint currently lacks `validate_transition()`, add it unless there is a defensible reason not to. + +Also verify whether: + +- `escalate` should remain audit-only +- or should transition into a distinct state + +If the product intentionally treats `escalate` as an audit event rather than a state change, the UI wording should reflect that more clearly. + +## 7) Files likely to be touched + +Expected primary files: + +- `web/src/pages/DocumentDetailPage.tsx` +- `api/app/api/v1/endpoints/review.py` + +Possible new files: + +- `web/src/pages/document-detail/workflow-actions.ts` +- `web/src/pages/document-detail/workflow-actions.test.ts` + +Possible related files: + +- `api/app/services/workflow.py` +- backend test files covering workflow endpoints +- frontend shared UI components if button treatment is generalized + +## 8) Risks and review traps + +The next session should avoid these mistakes: + +- fixing only the three visibly bad buttons and leaving the action logic fragmented +- hiding everything instead of designing a clearer terminal-state UX +- relying on the frontend only, without verifying backend transition enforcement +- skipping tests because the behavior “looks right” manually +- verifying only with one document status + +## 9) Overall acceptance criteria + +This handoff is complete only when all of the following are true: + +- The `Workflow actions` panel on document detail pages is driven by centralized, explicit action-availability rules. +- The UI no longer presents invalid workflow actions as active for terminal or incompatible states. +- The UX clearly communicates what actions are available now and why others are unavailable. +- Terminal-state documents have an intentional read-only action experience. +- Backend endpoints consistently reject illegal transitions. +- Automated tests cover the key action-state rules and any changed backend behavior. +- `make ci` passes. +- `make up` succeeds. +- Local browser verification confirms the fixes on at least: + - one `closed` document + - one `analyzed` or `under_review` document + - one `in_consultation` document + +## 10) Suggested execution order for the next session + +Use this order to keep the work controlled: + +1. Audit workflow/state rules and define the action matrix. +2. Refactor frontend action gating into a central helper/model. +3. Improve the action-panel UX and terminal-state treatment. +4. Re-check backend transition enforcement and patch any gaps. +5. Add frontend and backend tests. +6. Run `make ci`. +7. Run `make up`. +8. Verify the real app locally in the browser. + +## 11) Exit checklist for the next session + +Before closing the next session, confirm all of these explicitly: + +- action matrix implemented +- invalid active buttons removed or intentionally disabled with reasons +- terminal-state UX improved +- tests added and passing +- `make ci` passing +- `make up` passing +- local browser verification completed +- final note includes what changed, how it was validated, and any remaining risks diff --git a/metadata.json b/metadata.json index 1f6dbb6..1002b4a 100644 --- a/metadata.json +++ b/metadata.json @@ -1,6 +1,16 @@ { "name": "GovDoc SecureFlow", "description": "Public-sector document intake and triage system with AI-powered analysis and secure workflow management.", + "version": "0.1.0", "requestFramePermissions": [], - "majorCapabilities": [] + "majorCapabilities": [ + "AI Document Analysis", + "Intelligent Document Routing", + "Multi-Role Workflow Engine", + "RAG-Based Document Retrieval", + "Role-Based Access Control", + "Audit Trail", + "Consultation Workflow", + "Dashboard Analytics" + ] } diff --git a/web/package-lock.json b/web/package-lock.json index fd96599..9393f37 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,7 +32,8 @@ "tw-animate-css": "^1.4.0", "typescript": "5.9.3", "typescript-eslint": "^8.0.0", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.4" } }, "node_modules/@ampproject/remapping": { @@ -1465,6 +1466,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", @@ -1798,6 +1806,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2160,6 +2186,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2223,6 +2362,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2319,6 +2468,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2479,6 +2638,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2711,6 +2877,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2721,6 +2897,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3464,6 +3650,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3547,6 +3744,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3554,6 +3758,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -3832,6 +4049,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3842,6 +4066,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3926,6 +4164,23 @@ "node": ">=18" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3961,17 +4216,14 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=14.0.0" } }, "node_modules/ts-api-utils": { @@ -4197,17 +4449,94 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">=12" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, "node_modules/which": { @@ -4226,6 +4555,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/web/package.json b/web/package.json index 35abd8a..ad31b05 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "class-variance-authority": "^0.7.1", @@ -34,6 +36,7 @@ "tw-animate-css": "^1.4.0", "typescript": "5.9.3", "typescript-eslint": "^8.0.0", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.4" } } diff --git a/web/src/pages/DocumentDetailPage.tsx b/web/src/pages/DocumentDetailPage.tsx index a6a1335..fbfcf1b 100644 --- a/web/src/pages/DocumentDetailPage.tsx +++ b/web/src/pages/DocumentDetailPage.tsx @@ -15,10 +15,22 @@ import { ScrollText, Shield, AlertTriangle, + XCircle, + AlertCircle, + Info, } from 'lucide-react'; import { Link } from 'react-router-dom'; import { apiGet, apiPost } from '@/lib/api'; import { useRole } from '@/hooks/use-role'; +import { + getWorkflowActionStates, + getWorkflowStatusMessage, + ACTION_GROUP_LABELS, + isTerminalStatus, + toRoleId, + toButtonVariant, + type ButtonVariant, +} from '@/pages/document-detail/workflow-actions'; type AIAnalysis = { id: string; @@ -63,6 +75,11 @@ type ConsultationNote = { created_at: string; }; +type Department = { + id: string; + name: string; +}; + type DocDetail = { id: string; title: string; @@ -124,6 +141,10 @@ function DocumentDetailInner({ id }: { id: string }) { const [error, setError] = useState(null); const [showConsultInput, setShowConsultInput] = useState(false); const [consultBody, setConsultBody] = useState(''); + const [departments, setDepartments] = useState([]); + const [showRerouteInput, setShowRerouteInput] = useState(false); + const [rerouteDepartmentId, setRerouteDepartmentId] = useState(''); + const [rerouteRationale, setRerouteRationale] = useState(''); const [analysisView, setAnalysisView] = useState<'rendered' | 'raw'>('rendered'); const fetchDoc = async (opts?: { silent?: boolean }) => { @@ -144,9 +165,13 @@ function DocumentDetailInner({ id }: { id: string }) { let active = true; (async () => { try { - const data = await apiGet(`/documents/${id}`, role); + const [data, deptList] = await Promise.all([ + apiGet(`/documents/${id}`, role), + apiGet('/meta/departments', role), + ]); if (active) { setDoc(data); + setDepartments(deptList); setError(null); } } catch (err: unknown) { @@ -189,6 +214,8 @@ function DocumentDetailInner({ id }: { id: string }) { } }; + const availableDepartments = departments.filter((department) => department.id !== doc?.assigned_department_id); + if (loading) { return (
@@ -203,12 +230,41 @@ function DocumentDetailInner({ id }: { id: string }) { const primaryFile = doc.files[0]; const primaryArtifact = doc.artifacts[0]; const hasConsultationThread = doc.consultation_notes.length > 0 || doc.status === 'in_consultation'; + const departmentNames = Object.fromEntries(departments.map((department) => [department.id, department.name])); const analysesByStage: Record = {}; for (const a of doc.analyses) { analysesByStage[a.stage] = a; } const hasAnalysis = doc.analyses.length > 0; + // Workflow action model + const roleId = toRoleId(role); + const terminal = isTerminalStatus(doc.status); + const actionStates = terminal + ? { available: [], disabled: [], hidden: [] } + : getWorkflowActionStates(doc, roleId); + + const visibleActions = [ + ...actionStates.available.map((a) => ({ action: a, available: true, reason: null as string | null })), + ...actionStates.disabled.map((d) => ({ action: d.action, available: false, reason: d.reason })), + ]; + const actionGroupKeys = ['analysis', 'review', 'consultation', 'closeout'] as const; + + const getActionIcon = (actionId: string) => { + switch (actionId) { + case 'analyze': return ; + case 'approve-routing': return ; + case 'reroute': return ; + case 'request-consultation': return ; + case 'resolve-consultation': return ; + case 'escalate': return ; + case 'mark-out-of-scope': return ; + case 'close': return ; + default: return ; + } + }; + const displayDepartment = (value: string) => departmentNames[value] ?? humanizeEnum(value); + return (
@@ -351,10 +407,10 @@ function DocumentDetailInner({ id }: { id: string }) { {humanizeEnum(dec.decision)} {dec.suggested_department_id && ( - Suggested: {humanizeDepartment(dec.suggested_department_id)} + Suggested: {displayDepartment(dec.suggested_department_id)} )} {dec.final_department_id && ( - Final: {humanizeDepartment(dec.final_department_id)} + Final: {displayDepartment(dec.final_department_id)} )}
{dec.rationale &&

"{dec.rationale}"

} @@ -445,108 +501,239 @@ function DocumentDetailInner({ id }: { id: string }) { Workflow actions - - } - onClick={() => handleAction('analyze')} - disabled={actionLoading || hasAnalysis} - active={role === 'Intake Clerk' || role === 'Supervisor'} - variant="blue" - /> - } - onClick={() => handleAction('approve-routing')} - disabled={actionLoading || doc.status !== 'analyzed'} - active={role === 'Department Reviewer' || role === 'Supervisor'} - variant="emerald" - /> - {/* Request consultation */} - {(role === 'Department Reviewer' || role === 'Supervisor') && ( -
- {!showConsultInput ? ( - } - onClick={() => setShowConsultInput(true)} - disabled={actionLoading || !['under_review', 'routed'].includes(doc.status)} - active={true} - variant="purple" - /> - ) : ( -
-