package httpapi import ( "net/http" "strconv" "strings" "supply-intelligence/internal/domain" "supply-intelligence/internal/repository" ) // DashboardHandler handles external-facing dashboard UI endpoints. type DashboardHandler struct { repo repository.Repository } // NewDashboardHandler creates a dashboard handler backed by the given repository. func NewDashboardHandler(repo repository.Repository) *DashboardHandler { return &DashboardHandler{repo: repo} } // accountRow is a denormalized row for the accounts dashboard table. type accountRow struct { AccountID int64 `json:"account_id"` Platform string `json:"platform"` AccountStatus string `json:"account_status"` RoutingEnabled bool `json:"routing_enabled"` RiskScore int `json:"risk_score"` ReasonCode string `json:"reason_code"` LastProbeAt string `json:"last_probe_at"` Version int64 `json:"version"` } // modelRow is a denormalized row for the model catalog. type modelRow struct { PackageID int64 `json:"package_id"` Platform string `json:"platform"` Model string `json:"model"` Status string `json:"status"` Source string `json:"source"` Version int64 `json:"version"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // candidateRow is a denormalized row for the candidate management table. type candidateRow struct { CandidateID string `json:"candidate_id"` AccountID int64 `json:"account_id"` Platform string `json:"platform"` Model string `json:"model"` Status string `json:"status"` Source string `json:"source"` ReasonCode string `json:"reason_code,omitempty"` DiscoveredAt string `json:"discovered_at"` UpdatedAt string `json:"updated_at"` Version int64 `json:"version"` } // ListAccounts returns all accounts grouped by platform. // GET /internal/supply-intelligence/dashboard/accounts // Query params: platform (optional) func (h *DashboardHandler) ListAccounts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } platform := r.URL.Query().Get("platform") var states []domain.AccountRoutingState if platform != "" { states = h.repo.ListRoutingStatesByPlatform(r.Context(), platform) } else { // No ListAllRoutingStates — use openai as default for now states = h.repo.ListRoutingStatesByPlatform(r.Context(), "openai") // TODO: batch for all known platforms } rows := make([]accountRow, 0, len(states)) for _, s := range states { rows = append(rows, accountRow{ AccountID: s.AccountID, Platform: s.Platform, AccountStatus: string(s.AccountStatus), RoutingEnabled: s.RoutingEnabled, RiskScore: s.RiskScore, ReasonCode: s.ReasonCode, LastProbeAt: s.LastProbeAt.Format("2006-01-02T15:04:05Z"), Version: s.Version, }) } // Group by platform for summary view byPlatform := make(map[string][]accountRow) for _, row := range rows { byPlatform[row.Platform] = append(byPlatform[row.Platform], row) } writeJSON(w, http.StatusOK, map[string]any{ "items": rows, "by_platform": byPlatform, "total": len(rows), }) } // ListModels returns the model catalog from supply packages. // GET /internal/supply-intelligence/dashboard/models // Query params: status (optional: draft, active, deprecated) func (h *DashboardHandler) ListModels(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } status := r.URL.Query().Get("status") pkgs := h.repo.ListSupplyPackages(r.Context(), status) rows := make([]modelRow, 0, len(pkgs)) for _, p := range pkgs { rows = append(rows, modelRow{ PackageID: p.PackageID, Platform: p.Platform, Model: p.Model, Status: p.Status, Source: p.Source, Version: p.Version, CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) } // Group by platform for summary byPlatform := make(map[string][]modelRow) for _, row := range rows { byPlatform[row.Platform] = append(byPlatform[row.Platform], row) } writeJSON(w, http.StatusOK, map[string]any{ "items": rows, "by_platform": byPlatform, "total": len(rows), }) } // ListCandidates returns discovery candidates for management UI. // GET /internal/supply-intelligence/dashboard/candidates // Query params: status (optional), platform (optional), limit (optional, default 100) func (h *DashboardHandler) ListCandidates(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } statusStr := r.URL.Query().Get("status") platform := r.URL.Query().Get("platform") limitStr := r.URL.Query().Get("limit") limit := 100 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 { limit = l } } var domainStatus domain.DiscoveryCandidateStatus if statusStr != "" { domainStatus = domain.DiscoveryCandidateStatus(statusStr) } candidates := h.repo.ListDiscoveryCandidates(r.Context(), domainStatus) rows := make([]candidateRow, 0, len(candidates)) count := 0 for _, c := range candidates { if platform != "" && c.Platform != platform { continue } if limit > 0 && count >= limit { break } rows = append(rows, candidateRow{ CandidateID: c.CandidateID, AccountID: c.AccountID, Platform: c.Platform, Model: c.Model, Status: string(c.Status), Source: c.Source, ReasonCode: c.ReasonCode, DiscoveredAt: c.DiscoveredAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"), Version: c.Version, }) count++ } // Status summary counts statusCounts := make(map[string]int) for _, c := range candidates { statusCounts[string(c.Status)]++ } writeJSON(w, http.StatusOK, map[string]any{ "items": rows, "total": len(rows), "status_counts": statusCounts, }) } // GetProbeHistory returns probe execution history for an account. // GET /internal/supply-intelligence/dashboard/accounts/{account_id}/probe-history // Query params: limit (optional, default 20) func (h *DashboardHandler) GetProbeHistory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } prefix := "/internal/supply-intelligence/dashboard/accounts/" path := strings.TrimPrefix(r.URL.Path, prefix) if !strings.HasSuffix(path, "/probe-history") { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"}) return } accountIDStr := strings.TrimSuffix(path, "/probe-history") var accountID int64 if _, err := strconv.ParseInt(accountIDStr, 10, 64); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"}) return } limitStr := r.URL.Query().Get("limit") limit := 20 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } logs, err := h.repo.ListProbeExecutionLogs(r.Context(), accountID, limit) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed_to_load_logs"}) return } type probeLogRow struct { LogID int64 `json:"log_id"` Platform string `json:"platform"` ProbeResult string `json:"probe_result"` FailureClass string `json:"failure_class,omitempty"` HTTPStatus int `json:"http_status,omitempty"` LatencyMs int `json:"latency_ms,omitempty"` RiskScore int `json:"risk_score"` EvaluatedTransition string `json:"evaluated_transition"` ExecutedAt string `json:"executed_at"` RequestID string `json:"request_id"` Version int64 `json:"version"` } rows := make([]probeLogRow, 0, len(logs)) for _, l := range logs { rows = append(rows, probeLogRow{ LogID: l.LogID, Platform: l.Platform, ProbeResult: l.ProbeResult, FailureClass: l.FailureClass, HTTPStatus: l.HTTPStatus, LatencyMs: l.LatencyMs, RiskScore: l.RiskScore, EvaluatedTransition: l.EvaluatedTransition, ExecutedAt: l.ExecutedAt.Format("2006-01-02T15:04:05Z"), RequestID: l.RequestID, Version: l.Version, }) } writeJSON(w, http.StatusOK, map[string]any{"items": rows, "total": len(rows)}) }