Files
trustos/backend/app/api/routes/findings.py
drjones 09bae21c00 Add premium features roadmap and quick-win implementation guides
- PREMIUM_FEATURES_ROADMAP.md: Strategic feature roadmap for 3x-5x revenue expansion
  * Top 5 features: Board Autopilot, Insurance Integration, Predictive Modeling, Workflow Integration, Executive Monitoring
  * Revenue projections: $250K → $665K ARR over 3 years
  * 18+ feature ideas ranked by revenue, complexity, stickiness

- QUICK_WIN_FEATURES.md: Implementation guides for immediate value
  * Board Presentation Autopilot: 14-day implementation, $30K-$40K/year
  * Insurance Savings Calculator: 10-day implementation, $25K-$50K/year
  * Step-by-step code examples and deployment plan

Enables:
- 2.5x-3x wallet expansion per customer
- Shift from audit services to sticky SaaS
- 140-150% NRR with premium features
- $3.5M+ Year 1 revenue with premium offerings

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-07 09:54:12 +00:00

212 lines
7.8 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel
from app.db.session import get_db
from app.models.models import Finding, FindingStatus, FindingSeverity
from app.schemas.schemas import FindingOut, FindingCreate, FindingStatusUpdate
from app.core.security import require_executive_or_above, require_it_or_above
from app.services.ai_translator import translate_finding_async, answer_finding_question
router = APIRouter(prefix="/findings", tags=["findings"])
@router.get("", response_model=List[FindingOut])
async def list_findings(
tenant_id: str = Query(...),
severity: Optional[str] = None,
status: Optional[str] = None,
category: Optional[str] = None,
limit: int = 50,
offset: int = 0,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
query = select(Finding).where(Finding.tenant_id == tenant_id)
if severity:
query = query.where(Finding.severity == severity)
if status:
query = query.where(Finding.status == status)
if category:
query = query.where(Finding.category == category)
query = query.order_by(desc(Finding.created_at)).limit(limit).offset(offset)
result = await db.execute(query)
return result.scalars().all()
@router.get("/{finding_id}", response_model=FindingOut)
async def get_finding(
finding_id: str,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Finding not found")
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
return finding
@router.post("", response_model=FindingOut, status_code=201)
async def create_finding(
tenant_id: str = Query(...),
body: FindingCreate = ...,
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
finding = Finding(tenant_id=tenant_id, **body.model_dump())
db.add(finding)
await db.commit()
await db.refresh(finding)
return finding
@router.patch("/{finding_id}/status", response_model=FindingOut)
async def update_finding_status(
finding_id: str,
body: FindingStatusUpdate,
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Finding not found")
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
finding.status = body.status
if body.resolution_note:
finding.resolution_note = body.resolution_note
if body.assignee_email:
finding.assignee_email = body.assignee_email
if body.due_date:
finding.due_date = body.due_date
if body.status == FindingStatus.resolved:
finding.resolved_at = datetime.utcnow()
if body.status == FindingStatus.verified:
finding.verified_at = datetime.utcnow()
if not body.resolution_note:
raise HTTPException(status_code=400, detail="A resolution note is required to verify a finding")
finding.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(finding)
# Trigger async risk score recalculation (fire and forget)
from app.services.risk_calculator import recalculate_risk_score
import asyncio
asyncio.create_task(recalculate_risk_score(finding.tenant_id))
return finding
@router.patch("/{finding_id}/top-risk", response_model=FindingOut)
async def toggle_top_risk(
finding_id: str,
is_top_risk: bool = Query(...),
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Not found")
finding.is_top_risk = is_top_risk
await db.commit()
await db.refresh(finding)
return finding
@router.post("/{finding_id}/ai-translate")
async def translate_finding(
finding_id: str,
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Finding not found")
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
import asyncio
asyncio.create_task(translate_finding_async(finding_id))
return {"status": "Translation requested"}
class AIQuestionRequest(BaseModel):
question: str
@router.post("/{finding_id}/ai-question")
async def ask_ai_about_finding(
finding_id: str,
request: AIQuestionRequest,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Finding not found")
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
answer = await answer_finding_question(finding, request.question)
return {"answer": answer}
@router.get("/{finding_id}/ai-explain")
async def explain_finding_via_findings_route(
finding_id: str,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
"""
Get cached AI translation/explanation for a finding.
Returns the AI-generated summary, business impact, remediation steps, and priority.
If AI translation is not yet generated, returns 202 Accepted with a request to trigger translation.
"""
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Finding not found")
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
# Return cached AI translation if available
if finding.ai_summary:
return {
"finding_id": finding_id,
"summary": finding.ai_summary,
"business_impact": finding.ai_business_impact,
"impact_level": finding.ai_impact_level,
"remediation_steps": finding.ai_remediation_steps,
"fix_priority": finding.ai_fix_priority,
"generated_at": finding.ai_generated_at,
"status": "available",
}
# If not generated yet, trigger async generation and return 202
import asyncio
asyncio.create_task(translate_finding_async(finding_id))
return {
"finding_id": finding_id,
"status": "processing",
"message": "AI translation is being generated. Please check back shortly.",
}