Sync Status: per-source ingestion health page with nav warning badge

The n8n flows fail invisibly from the app's perspective (review UX-17,
the top trust gap). /api/v1/sync-status derives last-received + cadence
thresholds for BAC, salary, municipal, pensions and exchange rate from
existing data — no n8n integration needed. New Sincronización page
shows per-source freshness and a hint to check n8n; the sidebar item
gets an amber dot when any source is stale. Verified live against the
dev DB (correctly flags its stale BAC data at 43 days).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 18:03:55 -06:00
parent 8b7f3dff40
commit 4ceb67564a
8 changed files with 392 additions and 3 deletions

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.auth import get_current_user
from app.db import get_session
from app.services.sync_status import compute_sync_status
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
@router.get("/")
def sync_status(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
sources = compute_sync_status(session)
return {
"sources": sources,
"warnings": sum(1 for s in sources if s["status"] in ("warning", "never")),
}

View File

@@ -14,6 +14,7 @@ from app.api.v1.endpoints import (
salarios,
savings_accrual,
settings,
sync_status,
tokens,
transactions,
)
@@ -34,3 +35,4 @@ api_router.include_router(salarios.router)
api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router)
api_router.include_router(sync_status.router)

View File

@@ -0,0 +1,94 @@
"""Per-source ingestion health, derived from existing data (review UX-17).
The n8n flows fail silently from the app's perspective — a parser break just
looks like missing data. This derives "when did we last receive something from
each source" plus a cadence threshold, so a stalled flow becomes visible
in-app without any n8n integration.
"""
from dataclasses import dataclass
from sqlmodel import Session, func, select
from app.models.models import (
Bank,
ExchangeRate,
MunicipalReceipt,
PensionSnapshot,
Transaction,
TransactionSource,
TransactionType,
)
from app.timeutil import utcnow
@dataclass
class SourceSpec:
key: str
label: str
warn_after_days: float
description: str
# Cadences: BAC emails arrive near-daily with normal card use; salary is
# biweekly/monthly; municipal and pension are monthly; rates refresh every 6h.
SOURCES: list[SourceSpec] = [
SourceSpec("bac_credit_card", "BAC Tarjeta de Crédito", 7,
"Correos de compra parseados por n8n"),
SourceSpec("salary", "Depósitos de Salario", 35,
"Correos de depósito parseados por n8n"),
SourceSpec("municipal", "Recibos Municipales", 40,
"PDF mensual vía n8n (cron)"),
SourceSpec("pensions", "Estados de Pensión", 40,
"PDFs diarios/mensuales vía n8n"),
SourceSpec("exchange_rate", "Tipo de Cambio", 1,
"Actualización automática cada 6 horas"),
]
def _last_received(session: Session, key: str):
if key == "bac_credit_card":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.bank == Bank.BAC,
)
).one()
if key == "salary":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.transaction_type == TransactionType.SALARY
)
).one()
if key == "municipal":
return session.exec(select(func.max(MunicipalReceipt.created_at))).one()
if key == "pensions":
return session.exec(select(func.max(PensionSnapshot.created_at))).one()
if key == "exchange_rate":
return session.exec(select(func.max(ExchangeRate.fetched_at))).one()
raise ValueError(f"unknown sync source: {key}")
def compute_sync_status(session: Session) -> list[dict]:
now = utcnow()
out: list[dict] = []
for spec in SOURCES:
last = _last_received(session, spec.key)
if last is None:
status = "never"
age_days = None
else:
age_days = (now - last).total_seconds() / 86400
status = "ok" if age_days <= spec.warn_after_days else "warning"
out.append(
{
"key": spec.key,
"label": spec.label,
"description": spec.description,
"last_received": last.isoformat() if last else None,
"age_days": round(age_days, 1) if age_days is not None else None,
"warn_after_days": spec.warn_after_days,
"status": status,
}
)
return out