Files
clientflow_backend/app/api/internal.py

142 lines
5.0 KiB
Python

"""Small internal operational API.
These endpoints are intentionally read-only and are used by future HTMX/API
partials and external monitoring. They do not replace the current admin pages.
"""
from __future__ import annotations
from hmac import compare_digest
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy import text
from app.config import is_production_like_env, settings
from app.db import engine
def require_internal_access(request: Request) -> None:
expected = (settings.clientflow_admin_token or "").strip()
if not expected:
if is_production_like_env():
raise HTTPException(status_code=503, detail="internal api auth not configured")
return
received = (
request.headers.get("X-ClientFlow-Admin-Token")
or request.cookies.get("clientflow_admin_token")
or (request.query_params.get("admin_token") if not is_production_like_env() else None)
or ""
).strip()
if not received or not compare_digest(received, expected):
raise HTTPException(status_code=401, detail="internal api auth required")
router = APIRouter(prefix="/api/internal", tags=["internal"], dependencies=[Depends(require_internal_access)])
@router.get("/health")
def internal_health() -> dict:
return {
"status": "ok",
"app": settings.app_name,
"env": settings.env,
"jasmin_enabled": bool(settings.jasmin_enabled),
"packlink_enabled": bool(settings.packlink_enabled),
}
@router.post("/tasks/{task_id}/reply-draft")
def internal_task_reply_draft(task_id: str, payload: dict | None = Body(default=None)) -> dict:
"""Generate a safe editable reply draft for a task.
Internal API counterpart of the admin UI button. It persists a draft in
message_drafts, returns the suggested message and never sends it.
"""
payload = payload or {}
try:
from app.reply_assistant_service import generate_reply_draft
state = generate_reply_draft(
task_id,
template_code=str(payload.get("template_code") or "").strip() or None,
selected_document_ids=payload.get("selected_document_ids") or [],
persist=True,
)
return {
"draft_id": state.get("draft_id"),
"task_id": task_id,
"template_code": (state.get("template") or {}).get("code"),
"message_body": state.get("message_body"),
"blockers": state.get("blockers") or [],
"warnings": state.get("warnings") or [],
"email_agent": state.get("email_agent") or {},
"intent_gate": state.get("intent_gate") or {},
"business_knowledge": state.get("business_knowledge") or {},
}
except Exception as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/outbox/summary")
def outbox_summary() -> dict:
with engine.begin() as conn:
rows = conn.execute(text("""
SELECT target_system, status, count(*)::int AS total
FROM integration_outbox
GROUP BY target_system, status
ORDER BY target_system, status
""")).mappings().all()
summary: dict[str, dict[str, int]] = {}
for row in rows:
target = row["target_system"] or "unknown"
status = row["status"] or "unknown"
summary.setdefault(target, {})[status] = row["total"]
return {"items": summary}
@router.get("/documents/summary")
def documents_summary() -> dict:
with engine.begin() as conn:
rows = conn.execute(text("""
SELECT document_kind, status, count(*)::int AS total
FROM commercial_documents
GROUP BY document_kind, status
ORDER BY document_kind, status
""")).mappings().all()
summary: dict[str, dict[str, int]] = {}
for row in rows:
kind = row["document_kind"] or "unknown"
status = row["status"] or "unknown"
summary.setdefault(kind, {})[status] = row["total"]
return {"items": summary}
@router.get("/operations/summary")
def operations_summary() -> dict:
from app.operations_service import get_operations_summary
return get_operations_summary(limit=10)
@router.get("/system/health")
def system_health_summary() -> dict:
from app.operations_service import get_system_health_summary
return get_system_health_summary()
@router.get("/communications/summary")
def communications_summary() -> dict:
from app.communication_service import get_communications_summary
return get_communications_summary()
@router.get("/communications/recent")
def communications_recent(limit: int = 20) -> dict:
from app.communication_service import list_communications
return {"items": list_communications(limit=limit)}
@router.get("/forecast")
@router.get("/revenue-forecast")
def revenue_forecast(limit: int = 1000, month: str | None = None, metric: str = "invoiced") -> dict:
from app.revenue_forecast_service import get_revenue_forecast
return get_revenue_forecast(limit=limit, month=month, metric=metric)