feat(search): add role-aware backend search and truthful n8n status
Two new endpoints. GET /api/v1/search returns bounded typed results (vehicle, booking, data-quality-issue, application section) instead of the frontend guessing routes from regex patterns against public-ref prefixes; data-quality and manager-only sections are filtered server-side by role, and customers are deliberately never returned since no customer detail route exists in this PoC. GET /api/v1/integrations/status aggregates outbox delivery counts (pending/delivering/succeeded/failed) into a single truthful n8n state (disabled/unavailable/degraded/operational/no_evidence) instead of the UI showing whichever status the single most recent event happened to be in -- a vehicle_status_conflict-style bug where one stale failure or one lucky success could misreport the dispatcher's actual health. Also fixes a real config gap this surfaced: MCP_HUB_REGISTRATION_ENABLED was documented in .env.example but had no corresponding Settings field, so it was silently ignored by pydantic-settings' extra="ignore" and never actually read anywhere in the codebase.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import (
|
||||
CurrentUser,
|
||||
IntegrationStatusOut,
|
||||
McpHubIntegrationStatus,
|
||||
N8nIntegrationStatus,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
pending = counts.get("pending", 0)
|
||||
delivering = counts.get("delivering", 0)
|
||||
failed = counts.get("failed", 0)
|
||||
succeeded = counts.get("succeeded", 0)
|
||||
|
||||
latest_success_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||
)
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif failed > 0:
|
||||
state = "degraded"
|
||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
state=state,
|
||||
pending=pending,
|
||||
delivering=delivering,
|
||||
failed=failed,
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=IntegrationStatusOut)
|
||||
def integration_status(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> IntegrationStatusOut:
|
||||
return IntegrationStatusOut(
|
||||
n8n=_n8n_status(db),
|
||||
mcp_hub=McpHubIntegrationStatus(
|
||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
||||
|
||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||
|
||||
# Static application sections. Manager-only sections are filtered by role, mirroring the
|
||||
# same nav visibility rule Layout.tsx applies -- search must never surface a destination
|
||||
# the current role can't actually reach.
|
||||
_SECTIONS: list[dict] = [
|
||||
{
|
||||
"label": "Overview",
|
||||
"detail": "Operations dashboard",
|
||||
"link": "/dashboard",
|
||||
"terms": ["overview", "dashboard", "readiness"],
|
||||
},
|
||||
{
|
||||
"label": "Fleet",
|
||||
"detail": "Vehicle registry",
|
||||
"link": "/vehicles",
|
||||
"terms": ["fleet", "vehicle", "vehicles"],
|
||||
},
|
||||
{
|
||||
"label": "Bookings",
|
||||
"detail": "Rental bookings",
|
||||
"link": "/bookings",
|
||||
"terms": ["booking", "bookings", "rental"],
|
||||
},
|
||||
{
|
||||
"label": "Data quality",
|
||||
"detail": "Quality workbench",
|
||||
"link": "/data-quality",
|
||||
"terms": ["quality", "data quality", "issues"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"label": "Knowledge",
|
||||
"detail": "Procedure assistant",
|
||||
"link": "/knowledge",
|
||||
"terms": ["knowledge", "procedures"],
|
||||
},
|
||||
{
|
||||
"label": "Integrations",
|
||||
"detail": "Automation and integration status",
|
||||
"link": "/automation",
|
||||
"terms": ["automation", "integrations", "systems", "n8n"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"label": "Audit trail",
|
||||
"detail": "Audit history",
|
||||
"link": "/audit",
|
||||
"terms": ["audit", "history"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
def search(
|
||||
q: str = Query(min_length=1, max_length=100),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> SearchResponse:
|
||||
query = q.strip()
|
||||
normalized = query.lower()
|
||||
results: list[SearchResultItem] = []
|
||||
|
||||
for section in _SECTIONS:
|
||||
role = section.get("role")
|
||||
if role and user.role != role:
|
||||
continue
|
||||
terms: list[str] = section["terms"]
|
||||
if any(term in normalized or normalized in term for term in terms):
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="section",
|
||||
label=section["label"],
|
||||
detail=section["detail"],
|
||||
link=section["link"],
|
||||
)
|
||||
)
|
||||
|
||||
like = f"%{query}%"
|
||||
for v in db.scalars(
|
||||
select(Vehicle)
|
||||
.where(
|
||||
or_(
|
||||
Vehicle.public_ref.ilike(like),
|
||||
Vehicle.make.ilike(like),
|
||||
Vehicle.model.ilike(like),
|
||||
Vehicle.registration_number.ilike(like),
|
||||
Vehicle.location.ilike(like),
|
||||
)
|
||||
)
|
||||
.order_by(Vehicle.public_ref)
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="vehicle",
|
||||
label=v.public_ref,
|
||||
detail=f"{v.make} {v.model} · {v.location}",
|
||||
link=f"/vehicles/{v.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
for b in db.scalars(
|
||||
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="booking",
|
||||
label=b.public_ref,
|
||||
detail=b.status,
|
||||
link=f"/bookings/{b.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
# No customer detail route exists in this proof of concept, so customers are
|
||||
# deliberately never returned here -- there is nowhere useful to send the user.
|
||||
if user.role == "operations_manager":
|
||||
for i in db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(DataQualityIssue.public_ref.ilike(like))
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="data_quality_issue",
|
||||
label=i.public_ref,
|
||||
detail=i.rule_type.replace("_", " "),
|
||||
link=f"/data-quality/{i.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
return SearchResponse(query=query, results=results[:10])
|
||||
@@ -29,6 +29,7 @@ class Settings(BaseSettings):
|
||||
seed_dir: str = "/app/seed"
|
||||
knowledge_dir: str = "/app/knowledge/procedures"
|
||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||
mcp_hub_registration_enabled: bool = False
|
||||
cors_allow_origins: str = "http://localhost:1228"
|
||||
demo_today: str = "2026-08-01"
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ from app.api.routers import (
|
||||
dashboard,
|
||||
data_quality,
|
||||
demo,
|
||||
integration_status,
|
||||
integrations,
|
||||
knowledge,
|
||||
mcp_integrations,
|
||||
search,
|
||||
vehicles,
|
||||
workflows,
|
||||
)
|
||||
@@ -88,3 +90,5 @@ app.include_router(workflows.router)
|
||||
app.include_router(integrations.router)
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(mcp_integrations.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(integration_status.router)
|
||||
|
||||
@@ -163,6 +163,40 @@ class ApplyRecommendedStatusResult(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||
label: str
|
||||
detail: str
|
||||
link: str
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
query: str
|
||||
results: list[SearchResultItem]
|
||||
|
||||
|
||||
class N8nIntegrationStatus(BaseModel):
|
||||
configured: bool
|
||||
dispatch_enabled: bool
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
pending: int
|
||||
delivering: int
|
||||
failed: int
|
||||
succeeded: int
|
||||
latest_success_at: datetime | None
|
||||
latest_failure_at: datetime | None
|
||||
|
||||
|
||||
class McpHubIntegrationStatus(BaseModel):
|
||||
registration_enabled: bool
|
||||
state: Literal["not_configured", "configured"]
|
||||
|
||||
|
||||
class IntegrationStatusOut(BaseModel):
|
||||
n8n: N8nIntegrationStatus
|
||||
mcp_hub: McpHubIntegrationStatus
|
||||
|
||||
|
||||
class VehicleDetailOut(VehicleOut):
|
||||
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
||||
inspections: list[InspectionOut] = Field(default_factory=list)
|
||||
|
||||
Reference in New Issue
Block a user