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:
NuklearRabbit
2026-08-02 06:41:56 +02:00
parent 4bc3e33953
commit 4437b8792a
11 changed files with 596 additions and 46 deletions
+146
View File
@@ -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])