Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8310558
[AI] Make review queue rows clickable (FEEDBACK-01)
dinosaurchi Apr 17, 2026
411f107
[AI] Show processing steps and review-case CTA on intake (FEEDBACK-02)
dinosaurchi Apr 17, 2026
e4a578a
[AI] Format file metadata size as B/KB/MB/GB (FEEDBACK-04)
dinosaurchi Apr 17, 2026
8b89ca5
[AI] Two-sided consultation chat, chronological, consultant replies (…
dinosaurchi Apr 17, 2026
a2592fb
[AI] Workflow actions guide with pipeline + role context (FEEDBACK-06)
dinosaurchi Apr 17, 2026
cb1305a
[AI] Add favicon for browser tab logo
dinosaurchi Apr 17, 2026
20b8d3f
[AI] Polish Consultation + Response sidebars and chat composer (FEEDB…
dinosaurchi Apr 17, 2026
c0a077e
[AI] Add lazy-loading pagination to review queue (FEEDBACK-08)
dinosaurchi Apr 17, 2026
18d1ecf
[AI] Fit Consultation + Response pages to viewport and lazy-render si…
dinosaurchi Apr 17, 2026
2ba0c28
[AI] Polish Dashboard and Intake alignment (FEEDBACK-10)
dinosaurchi Apr 17, 2026
1d0186e
[AI] Extend e2e suite for pagination + in-place scrolling
dinosaurchi Apr 17, 2026
1889c34
[AI] Fix dashboard and intake layout alignment
dinosaurchi Apr 18, 2026
b5bc5e3
[AI] Surface reroute result with assigned-department line and success…
dinosaurchi Apr 18, 2026
6389458
[AI] Expose next-owner handoff after reroute (FEEDBACK-12)
dinosaurchi Apr 18, 2026
324dc34
[AI] Promote forward action + clarify next-step copy on document deta…
dinosaurchi Apr 18, 2026
3c11c99
[AI] Fix resolve-consultation to respect parallel open notes (FEEDBAC…
dinosaurchi Apr 18, 2026
d8f5cf9
[AI] Warn on orphaned consultation notes + consultation deep-link (FE…
dinosaurchi Apr 18, 2026
a536c1d
[AI] E2E: parallel consultation notes resolve correctly (FEEDBACK-13)
dinosaurchi Apr 18, 2026
2a18741
[AI] Resolve consultation notes inline + status-aware hint (FEEDBACK-13)
dinosaurchi Apr 18, 2026
336ae7c
[AI] fix(api): approve-routing must persist assigned_department_id
dinosaurchi Apr 18, 2026
f6318ad
[AI] fix(web): reroute-not-close when under_review has no assigned dept
dinosaurchi Apr 18, 2026
75c00f3
[AI] feat(api): separate document approval from close
dinosaurchi Apr 18, 2026
377f25c
[AI] feat(web): two-step Approve document then Close document
dinosaurchi Apr 18, 2026
1f6f8c0
[AI] fix(web): workflow progress stepper matches real status
dinosaurchi Apr 18, 2026
ed868d9
[AI] fix(web): show real API errors on intake + allow large uploads b…
dinosaurchi Apr 18, 2026
c8d5fa4
[AI] ux: link to consultation thread after requesting consultation
dinosaurchi Apr 18, 2026
f0b73a7
[AI] ux: always show consultation + review links on document consulta…
dinosaurchi Apr 18, 2026
14fc716
[AI] docs(ui): explain Response queue Pending vs Dispatched
dinosaurchi Apr 18, 2026
e71bcbd
[AI] Link document actions to Response page with ?doc= deep links
dinosaurchi Apr 18, 2026
c271bf9
[AI] Add WorkflowDocConnections strip across document, queues, and lists
dinosaurchi Apr 18, 2026
d8a2c45
[AI] Response queue: per-item hard links and synced ?doc= URL
dinosaurchi Apr 18, 2026
dd83524
[AI] Document page: persistent workflow shortcuts; keep success flash…
dinosaurchi Apr 18, 2026
68807e4
[AI] Review queue deep link ?doc=; API search by document id
dinosaurchi Apr 18, 2026
50afafd
[AI] Response page: collapsible How you get a result here (default cl…
dinosaurchi Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion api/app/api/v1/endpoints/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,26 @@

@router.get("/", response_model=List[DocumentListOut])
def get_documents(
response: Response,
status: Optional[str] = None,
department_id: Optional[str] = None,
q: Optional[str] = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
db: Session = Depends(deps.get_db),
role: CurrentRole = Depends(deps.get_current_role),
):
repo = DocumentRepository(db)
docs = repo.list(status=status, department_id=department_id, q=q)
total = repo.count(status=status, department_id=department_id, q=q)
docs = repo.list(
status=status,
department_id=department_id,
q=q,
offset=offset,
limit=limit,
)
response.headers["X-Total-Count"] = str(total)
response.headers["Access-Control-Expose-Headers"] = "X-Total-Count"
return docs


Expand Down
182 changes: 147 additions & 35 deletions api/app/api/v1/endpoints/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@
router = APIRouter(prefix="/documents", tags=["workflow"])


def _count_unresolved_consultation_notes(db: Session, document_id: str) -> int:
return (
db.query(ConsultationNote)
.filter(
ConsultationNote.document_id == document_id,
ConsultationNote.resolved_at.is_(None),
)
.count()
)


@router.post("/{document_id}/approve-routing", response_model=WorkflowActionResponse)
async def approve_routing(
document_id: str,
Expand All @@ -44,15 +55,46 @@ async def approve_routing(
detail={"error": {"code": "INVALID_TRANSITION", "message": str(e), "details": {}}},
)

# Look up the AI-produced routing decision (created during analysis with
# decision="accepted" but final_department_id=None pending human review).
# Accepting the AI suggestion means copying its suggested_department_id
# onto both the routing decision and the document itself.
ai_routing = (
db.query(RoutingDecision)
.filter(RoutingDecision.document_id == document_id)
.order_by(RoutingDecision.created_at.desc())
.first()
)
if ai_routing is None or not ai_routing.suggested_department_id:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "NO_AI_ROUTING",
"message": (
"Document has no AI routing suggestion to approve. "
"Re-run analysis or use reroute-document instead."
),
"details": {},
}
},
)

ai_routing.final_department_id = ai_routing.suggested_department_id
ai_routing.decided_by_role = role.id

document.status = DocumentStatus.routed
document.assigned_reviewer_role = role.id
document.assigned_department_id = ai_routing.suggested_department_id

write_audit_event(
db,
document_id=document_id,
actor_role=role.id,
event_type="workflow.transition",
from_state=old_status,
to_state="routed",
metadata_json={"department_id": ai_routing.suggested_department_id},
)
db.commit()
return {"document": document, "message": "Routing approved"}
Expand Down Expand Up @@ -188,10 +230,13 @@ async def resolve_consultation(
detail={"error": {"code": "INVALID_TRANSITION", "message": "Note already resolved", "details": {}}},
)

note.resolved_at = func.now()
document = db.query(Document).filter(Document.id == document_id).first()
if document:
old_status = document.status.value
# Guard against resolving notes on documents in terminal states.
# The only statuses where resolving a note makes sense are
# `in_consultation` (the normal case) and `under_review` (a prior
# run of this endpoint already flipped the doc back but an earlier
# bug left sibling notes open — resolving them is still valid).
try:
validate_transition(document.status, DocumentStatus.under_review)
except InvalidTransitionError as e:
Expand All @@ -200,15 +245,43 @@ async def resolve_consultation(
detail={"error": {"code": "INVALID_TRANSITION", "message": str(e), "details": {}}},
)

document.status = DocumentStatus.under_review
write_audit_event(
db,
document_id=document_id,
actor_role=role.id,
event_type="workflow.transition",
from_state=old_status,
to_state="under_review",
note.resolved_at = func.now()
# `flush` so the just-resolved note's `resolved_at` is visible to the
# subsequent "are there still open notes?" query below.
db.flush()

if document:
unresolved_remaining = (
db.query(ConsultationNote)
.filter(
ConsultationNote.document_id == document_id,
ConsultationNote.resolved_at.is_(None),
)
.count()
)

# Only flip back to `under_review` once every open note has been
# resolved. In a parallel-consultation scenario (e.g. reviewer
# asked both Legal and Consultant) resolving a single note should
# leave the document on `in_consultation` until the last one is
# handled. If the document is already on `under_review` (data
# drift from an older buggy run), just resolve the note and leave
# the status alone.
if (
unresolved_remaining == 0
and document.status == DocumentStatus.in_consultation
):
old_status = document.status.value
document.status = DocumentStatus.under_review
write_audit_event(
db,
document_id=document_id,
actor_role=role.id,
event_type="workflow.transition",
from_state=old_status,
to_state="under_review",
metadata_json={"resolved_note_id": note_id},
)
db.commit()
return {"document": document, "consultation_note": note}

Expand Down Expand Up @@ -273,13 +346,66 @@ async def mark_out_of_scope(
return {"document": document, "message": "Document marked as out of scope"}


@router.post("/{document_id}/approve", response_model=WorkflowActionResponse)
async def approve_document(
document_id: str,
role: CurrentRole = Depends(require_action("documents.approve")),
db: Session = Depends(get_db),
):
"""Approve the document — transitions to `approved` (archive/close is separate).

Allowed from `under_review` or `in_consultation`. Blocked while consultation
notes are still open so approval cannot skip parallel review work.
"""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
raise HTTPException(
status_code=404,
detail={"error": {"code": "NOT_FOUND", "message": "Document not found", "details": {}}},
)

open_notes = _count_unresolved_consultation_notes(db, document_id)
if open_notes > 0:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "OPEN_CONSULTATION_NOTES",
"message": "Resolve all consultation notes before approving.",
"details": {"open_notes": open_notes},
}
},
)

try:
validate_transition(document.status, DocumentStatus.approved)
except InvalidTransitionError as e:
raise HTTPException(
status_code=400,
detail={"error": {"code": "INVALID_TRANSITION", "message": str(e), "details": {}}},
)

old_status = document.status.value
document.status = DocumentStatus.approved
write_audit_event(
db,
document_id=document_id,
actor_role=role.id,
event_type="workflow.transition",
from_state=old_status,
to_state="approved",
)
db.commit()
return {"document": document, "message": "Document approved"}


@router.post("/{document_id}/close", response_model=WorkflowActionResponse)
async def close_document(
document_id: str,
role: CurrentRole = Depends(require_action("documents.close")),
db: Session = Depends(get_db),
):
"""Close document — supervisor only."""
"""Close document — supervisor only, from `approved` to `closed`."""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
raise HTTPException(
Expand All @@ -288,36 +414,22 @@ async def close_document(
)

try:
# Transition to approved if not already
if document.status != DocumentStatus.approved:
validate_transition(document.status, DocumentStatus.approved)
old_status = document.status.value
document.status = DocumentStatus.approved
write_audit_event(
db,
document_id=document_id,
actor_role=role.id,
event_type="workflow.transition",
from_state=old_status,
to_state="approved",
)

validate_transition(document.status, DocumentStatus.closed)
old_status = document.status.value
document.status = DocumentStatus.closed
write_audit_event(
db,
document_id=document_id,
actor_role=role.id,
event_type="workflow.transition",
from_state=old_status,
to_state="closed",
)
except InvalidTransitionError as e:
raise HTTPException(
status_code=400,
detail={"error": {"code": "INVALID_TRANSITION", "message": str(e), "details": {}}},
)

old_status = document.status.value
document.status = DocumentStatus.closed
write_audit_event(
db,
document_id=document_id,
actor_role=role.id,
event_type="workflow.transition",
from_state=old_status,
to_state="closed",
)
db.commit()
return {"document": document, "message": "Document closed"}
1 change: 1 addition & 0 deletions api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async def lifespan(_app: FastAPI):
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Total-Count"],
)


Expand Down
48 changes: 43 additions & 5 deletions api/app/repositories/document.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any

from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload

from app.repositories.base import CRUDBase
Expand Down Expand Up @@ -36,17 +37,54 @@ def get_with_relations(self, document_id: str) -> Document | None:
.first()
)

def list(
self, status: str | None = None, department_id: str | None = None, q: str | None = None, limit: int = 200
) -> list[Document]:
def _list_query(
self,
status: str | None = None,
department_id: str | None = None,
q: str | None = None,
):
query = self.db.query(Document)
if status:
query = query.filter(Document.status == status)
if department_id:
query = query.filter(Document.assigned_department_id == department_id)
if q:
query = query.filter(Document.title.ilike(f"%{q}%"))
return query.order_by(Document.created_at.desc()).limit(limit).all()
qs = q.strip()
if qs:
query = query.filter(
or_(
Document.title.ilike(f"%{qs}%"),
Document.id == qs,
Document.id.like(f"{qs}%"),
)
)
return query

def list(
self,
status: str | None = None,
department_id: str | None = None,
q: str | None = None,
offset: int = 0,
limit: int = 200,
) -> list[Document]:
return (
self._list_query(status=status, department_id=department_id, q=q)
.order_by(Document.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)

def count(
self,
status: str | None = None,
department_id: str | None = None,
q: str | None = None,
) -> int:
return self._list_query(
status=status, department_id=department_id, q=q
).count()

def update(self, document: Document, **kwargs) -> Document:
for key, value in kwargs.items():
Expand Down
Loading
Loading