TB-25 — Portal Router Consolidation

Status: Plan only. Approved direction pending. Origin: Session 47 audit (2026-04-17). Goal: Reduce 32 portal_*.py routers in /opt/unlikely-api/routes/ (22,811 LOC) to 8 domain-grouped routers while preserving every HTTP path the frontend depends on.

Proposed target architecture (8 routers)

#Target fileAbsorbsRiskNotes
Aportal_auth.pyunchangedSelf-contained. No merge.
Bportal_projects_core.pyportal_projects + portal_status + portal_cert_board + portal_tempMEDportal_status is misnamed (dependency health, not project status). portal_temp may be dead code — grep frontend first, delete if unused.
Cportal_workflow.pyintake_requests + sow_gate + sched_approve + schedule_changes + calendar_drag + cancel + holding_pool + escalations + slaHIGHNine files, 4742 LOC, lateral dependencies. Prefer a routes/workflow/ subpackage over a single monolith.
Dportal_messaging.pymessages + notifications + chat + rfiHIGHportal_chat.py alone is 1524 LOC with WebSocket state. Prefer routes/messaging/ subpackage.
Eportal_billing.pycert_invoice + payment_recon + reconciliationMED-HIGHTouches money. Defer to dedicated sprint.
Fportal_field.pyinspections + failed_inspections + missed_appointments + return_visits (portal half)MED_record_missed_flag is imported by 3 files — lift to services/ first.
Gportal_files.pyunchangedSelf-contained. No merge.
Hportal_admin.pyactions + audit + reporting + dashboard + ssiLOWRecommended first merge.
Iportal_friday.pyunchangedSplit candidate, not merge. _TOOL_EXECUTORS / _ALL_TOOLS consumed by friday_engine.py.

Scope: 5 files → 1. 25 endpoints. 747 LOC. Zero cross-module coupling.

Files absorbed: portal_actions.py, portal_audit.py, portal_reporting.py, portal_dashboard.py, portal_ssi.py.

Why first

  • No other file in the codebase imports from any of these five.
  • Mount prefixes are disjoint (/portal/actions, /portal/audit, /portal/reporting, /portal/dashboard, /portal/ssi) — no route-path conflicts.
  • No WebSockets, no background workers, no shared state.
  • Small enough to review in one PR.

Step-by-step

  1. Create routes/portal_admin.py with 5 named APIRouter() instances (pattern already used by portal_return_visits.py):
    actions_router = APIRouter()
    audit_router = APIRouter()
    reporting_router = APIRouter()
    dashboard_router = APIRouter()
    ssi_router = APIRouter()
  2. Copy handlers verbatim from each source file, replacing the single router reference with the appropriate named router. All paths, models, dependencies, response types unchanged.
  3. Consolidate imports at top of the new file (dedupe).
  4. Update main.py imports (lines 106, 107, 109, 117, 144):
    from routes.portal_admin import (
        actions_router as portal_actions_router,
        audit_router as portal_audit_router,
        reporting_router as portal_reporting_router,
        dashboard_router as portal_dashboard_router,
        ssi_router as portal_ssi_router,
    )
  5. Delete 5 old files.
  6. Rebuild: cd /opt/unlikely-api && docker compose up -d --build unlikely-api && docker compose restart worker scheduler.
  7. Smoke test each path from the portal (no path changes — pure regression check):
    • GET /portal/actions/summary
    • one endpoint each from /portal/audit, /portal/reporting, /portal/dashboard
    • GET /portal/ssi/submissions
    • GET /openapi.json — confirm 25 endpoints still present.
  8. Commit + push per standing policy.

Rollback

git revert <merge commit> restores exact prior state — change is additive-then-subtractive with no path renames.

Deferred / high-risk

  1. portal_friday (3611 LOC). Split candidate, not merge. Extract tool definitions into services/friday_tools.py first.
  2. portal_projects (2551 LOC). Core read surface. Split out site-visit and flag endpoints rather than absorb more.
  3. portal_chat (1524 LOC, WebSocket, push subs). Use a routes/messaging/ subpackage — never merge into a single file.
  4. portal_cert_invoice + portal_payment_recon (2537 LOC combined). Money. Dedicated sprint.
  5. Workflow cluster (9 files, 4742 LOC, lateral deps). Needs its own effort.
  6. portal_missed_appointments exports _record_missed_flag (used by trip_charge, whatsapp, supabase_write_service). Lift to services/missed_appointments.py first, then merge the route shell.
  7. portal_intake_requests exports create_intake_request_from_confirm (used by intake.py). Same lift-to-services pattern first.

Other observations

  • Route layer leaks into service responsibilities in 4 places — any serious consolidation should lift these to services/ first, then collapse the route file.
  • portal_status.py is misnamed (dependency health, not project status). Consider renaming to portal_health.py or folding into /monitor in a follow-up pass.
  • portal_temp.py’s two endpoints (/temp-report, /temp-report-2025) may be dead code — grep the portal before absorbing.
  • Mount-prefix convention is inconsistent: 6 files share /portal with per-route paths while the rest have dedicated sub-prefixes. Fix only in a dedicated API-versioning sprint — never during a consolidation pass.
  • portal_return_visits.py is the template for how consolidated files should export multiple named routers.