package handler import ( "database/sql" "encoding/json" "fmt" "log/slog" "net/http" "strconv" "github.com/jackc/pgx/v5/pgxpool" "otel-bi-analytics/internal/analytics" "otel-bi-analytics/internal/export" "otel-bi-analytics/internal/scheduler" ) type Handler struct { awDB *sql.DB wwiDB *sql.DB pgPool *pgxpool.Pool sched *scheduler.Scheduler defaultTopN int defaultForecastDays int defaultHistoryDays int } func New(awDB, wwiDB *sql.DB, pgPool *pgxpool.Pool, sched *scheduler.Scheduler, topN, forecastDays, historyDays int) *Handler { return &Handler{ awDB: awDB, wwiDB: wwiDB, pgPool: pgPool, sched: sched, defaultTopN: topN, defaultForecastDays: forecastDays, defaultHistoryDays: historyDays, } } // RegisterRoutes wires all routes into the given mux (Go 1.22 method+path syntax). func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /health", h.Health) mux.HandleFunc("GET /aw/sales/kpis", h.AWKPIs) mux.HandleFunc("GET /aw/sales/history", h.AWHistory) mux.HandleFunc("GET /aw/sales/forecast", h.AWForecast) mux.HandleFunc("GET /aw/reps/scores", h.AWRepScores) mux.HandleFunc("GET /aw/products/demand", h.AWProductDemand) mux.HandleFunc("GET /aw/anomalies", h.AWAnomalies) mux.HandleFunc("GET /aw/data-quality", h.AWDataQuality) mux.HandleFunc("GET /aw/export/sales-history", h.ExportAWSalesHistory) mux.HandleFunc("GET /aw/export/sales-forecast", h.ExportAWSalesForecast) mux.HandleFunc("GET /aw/export/rep-scores", h.ExportAWRepScores) mux.HandleFunc("GET /aw/export/product-demand", h.ExportAWProductDemand) mux.HandleFunc("GET /wwi/sales/kpis", h.WWIKPIs) mux.HandleFunc("GET /wwi/stock/recommendations", h.WWIReorderRecommendations) mux.HandleFunc("GET /wwi/suppliers/scores", h.WWISupplierScores) mux.HandleFunc("POST /wwi/scenarios", h.WWIWhatIfScenario) mux.HandleFunc("GET /wwi/data-quality", h.WWIDataQuality) mux.HandleFunc("GET /wwi/export/stock-recommendations", h.ExportWWIStockRecommendations) mux.HandleFunc("GET /wwi/export/supplier-scores", h.ExportWWISupplierScores) mux.HandleFunc("POST /scheduler/aw/{job_name}/trigger", h.TriggerAWJob) mux.HandleFunc("POST /scheduler/wwi/{job_name}/trigger", h.TriggerWWIJob) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { slog.Error("json encode failed", "err", err) } } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func queryInt(r *http.Request, key string, defaultVal int) int { s := r.URL.Query().Get(key) if s == "" { return defaultVal } v, err := strconv.Atoi(s) if err != nil || v <= 0 { return defaultVal } return v } func writeXLSX(w http.ResponseWriter, filename string, rowCount int, data []byte) { w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) w.Header().Set("X-Row-Count", strconv.Itoa(rowCount)) w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func toMaps(v any) []map[string]any { b, _ := json.Marshal(v) var out []map[string]any _ = json.Unmarshal(b, &out) if out == nil { out = []map[string]any{} } return out } // --------------------------------------------------------------------------- // Analytics handlers // --------------------------------------------------------------------------- func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) AWKPIs(w http.ResponseWriter, r *http.Request) { result, err := analytics.AWGetSalesKPIs(r.Context(), h.awDB) if err != nil { slog.Error("AWGetSalesKPIs", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handler) AWHistory(w http.ResponseWriter, r *http.Request) { daysBack := queryInt(r, "days_back", h.defaultHistoryDays) result, err := analytics.AWGetSalesHistory(r.Context(), h.awDB, daysBack) if err != nil { slog.Error("AWGetSalesHistory", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } if result == nil { result = []analytics.DailySalesPoint{} } writeJSON(w, http.StatusOK, result) } func (h *Handler) AWForecast(w http.ResponseWriter, r *http.Request) { horizonDays := queryInt(r, "horizon_days", h.defaultForecastDays) result, err := analytics.AWGetSalesForecast(r.Context(), h.awDB, horizonDays) if err != nil { slog.Error("AWGetSalesForecast", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } if result == nil { result = []analytics.ForecastPoint{} } writeJSON(w, http.StatusOK, result) } func (h *Handler) AWRepScores(w http.ResponseWriter, r *http.Request) { topN := queryInt(r, "top_n", h.defaultTopN) result, err := analytics.AWGetRepScores(r.Context(), h.awDB, topN) if err != nil { slog.Error("AWGetRepScores", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } if result == nil { result = []analytics.RepScore{} } writeJSON(w, http.StatusOK, result) } func (h *Handler) AWProductDemand(w http.ResponseWriter, r *http.Request) { topN := queryInt(r, "top_n", h.defaultTopN) result, err := analytics.AWGetProductDemand(r.Context(), h.awDB, topN) if err != nil { slog.Error("AWGetProductDemand", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } if result == nil { result = []analytics.ProductDemand{} } writeJSON(w, http.StatusOK, result) } func (h *Handler) AWAnomalies(w http.ResponseWriter, r *http.Request) { result, err := analytics.AWRunAnomalyDetection(r.Context(), h.awDB) if err != nil { slog.Error("AWRunAnomalyDetection", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } if result == nil { result = []analytics.AnomalyPoint{} } writeJSON(w, http.StatusOK, result) } func (h *Handler) AWDataQuality(w http.ResponseWriter, r *http.Request) { result, err := analytics.AWRunDataQualityCheck(r.Context(), h.awDB) if err != nil { slog.Error("AWRunDataQualityCheck", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handler) WWIKPIs(w http.ResponseWriter, r *http.Request) { result, err := analytics.WWIGetSalesKPIs(r.Context(), h.wwiDB) if err != nil { slog.Error("WWIGetSalesKPIs", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handler) WWIReorderRecommendations(w http.ResponseWriter, r *http.Request) { result, err := analytics.WWIGetReorderRecommendations(r.Context(), h.wwiDB) if err != nil { slog.Error("WWIGetReorderRecommendations", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } if result == nil { result = []analytics.ReorderRecommendation{} } writeJSON(w, http.StatusOK, result) } func (h *Handler) WWISupplierScores(w http.ResponseWriter, r *http.Request) { topN := queryInt(r, "top_n", h.defaultTopN) result, err := analytics.WWIGetSupplierScores(r.Context(), h.wwiDB, topN) if err != nil { slog.Error("WWIGetSupplierScores", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } if result == nil { result = []analytics.SupplierScore{} } writeJSON(w, http.StatusOK, result) } func (h *Handler) WWIWhatIfScenario(w http.ResponseWriter, r *http.Request) { var body struct { StockItemKey int `json:"stock_item_key"` DemandMultiplier float64 `json:"demand_multiplier"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if body.StockItemKey <= 0 { writeError(w, http.StatusBadRequest, "stock_item_key must be > 0") return } if body.DemandMultiplier <= 0 { body.DemandMultiplier = 1.0 } result, err := analytics.WWICreateWhatIfScenario(r.Context(), h.wwiDB, body.StockItemKey, body.DemandMultiplier) if err != nil { slog.Error("WWICreateWhatIfScenario", "err", err) writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handler) WWIDataQuality(w http.ResponseWriter, r *http.Request) { result, err := analytics.WWIRunDataQualityCheck(r.Context(), h.wwiDB) if err != nil { slog.Error("WWIRunDataQualityCheck", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } // --------------------------------------------------------------------------- // Export handlers // --------------------------------------------------------------------------- func (h *Handler) ExportAWSalesHistory(w http.ResponseWriter, r *http.Request) { daysBack := queryInt(r, "days_back", h.defaultHistoryDays) data, err := analytics.AWGetSalesHistory(r.Context(), h.awDB, daysBack) if err != nil { slog.Error("ExportAWSalesHistory", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } cols := []export.Column{ {Key: "date", Label: "Date"}, {Key: "total_revenue", Label: "Total Revenue"}, {Key: "total_orders", Label: "Total Orders"}, {Key: "avg_order_value", Label: "Avg Order Value"}, } b, err := export.ToXLSXBytes(r.Context(), "Sales History", cols, toMaps(data)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeXLSX(w, "aw_sales_history.xlsx", len(data), b) } func (h *Handler) ExportAWSalesForecast(w http.ResponseWriter, r *http.Request) { horizonDays := queryInt(r, "horizon_days", h.defaultForecastDays) data, err := analytics.AWGetSalesForecast(r.Context(), h.awDB, horizonDays) if err != nil { slog.Error("ExportAWSalesForecast", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } cols := []export.Column{ {Key: "date", Label: "Date"}, {Key: "predicted_revenue", Label: "Predicted Revenue"}, {Key: "lower_bound", Label: "Lower Bound"}, {Key: "upper_bound", Label: "Upper Bound"}, } b, err := export.ToXLSXBytes(r.Context(), "Sales Forecast", cols, toMaps(data)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeXLSX(w, "aw_sales_forecast.xlsx", len(data), b) } func (h *Handler) ExportAWRepScores(w http.ResponseWriter, r *http.Request) { topN := queryInt(r, "top_n", h.defaultTopN) data, err := analytics.AWGetRepScores(r.Context(), h.awDB, topN) if err != nil { slog.Error("ExportAWRepScores", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } cols := []export.Column{ {Key: "rep_name", Label: "Sales Rep"}, {Key: "total_revenue", Label: "Total Revenue"}, {Key: "total_orders", Label: "Total Orders"}, {Key: "avg_order_value", Label: "Avg Order Value"}, {Key: "performance_score", Label: "Performance Score"}, } b, err := export.ToXLSXBytes(r.Context(), "Rep Scores", cols, toMaps(data)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeXLSX(w, "aw_rep_scores.xlsx", len(data), b) } func (h *Handler) ExportAWProductDemand(w http.ResponseWriter, r *http.Request) { topN := queryInt(r, "top_n", h.defaultTopN) data, err := analytics.AWGetProductDemand(r.Context(), h.awDB, topN) if err != nil { slog.Error("ExportAWProductDemand", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } cols := []export.Column{ {Key: "product_name", Label: "Product"}, {Key: "category", Label: "Category"}, {Key: "total_quantity", Label: "Total Quantity"}, {Key: "total_revenue", Label: "Total Revenue"}, {Key: "demand_score", Label: "Demand Score"}, } b, err := export.ToXLSXBytes(r.Context(), "Product Demand", cols, toMaps(data)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeXLSX(w, "aw_product_demand.xlsx", len(data), b) } func (h *Handler) ExportWWIStockRecommendations(w http.ResponseWriter, r *http.Request) { data, err := analytics.WWIGetReorderRecommendations(r.Context(), h.wwiDB) if err != nil { slog.Error("ExportWWIStockRecommendations", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } cols := []export.Column{ {Key: "stock_item_name", Label: "Stock Item"}, {Key: "current_stock", Label: "Current Stock"}, {Key: "avg_daily_demand", Label: "Avg Daily Demand"}, {Key: "days_until_stockout", Label: "Days Until Stockout"}, {Key: "recommended_reorder_qty", Label: "Recommended Reorder Qty"}, {Key: "urgency", Label: "Urgency"}, } b, err := export.ToXLSXBytes(r.Context(), "Stock Recommendations", cols, toMaps(data)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeXLSX(w, "wwi_stock_recommendations.xlsx", len(data), b) } func (h *Handler) ExportWWISupplierScores(w http.ResponseWriter, r *http.Request) { topN := queryInt(r, "top_n", h.defaultTopN) data, err := analytics.WWIGetSupplierScores(r.Context(), h.wwiDB, topN) if err != nil { slog.Error("ExportWWISupplierScores", "err", err) writeError(w, http.StatusInternalServerError, err.Error()) return } cols := []export.Column{ {Key: "supplier_name", Label: "Supplier"}, {Key: "total_orders", Label: "Total Orders"}, {Key: "on_time_delivery_rate", Label: "On-Time Delivery Rate"}, {Key: "avg_lead_time_days", Label: "Avg Lead Time (Days)"}, {Key: "performance_score", Label: "Performance Score"}, } b, err := export.ToXLSXBytes(r.Context(), "Supplier Scores", cols, toMaps(data)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeXLSX(w, "wwi_supplier_scores.xlsx", len(data), b) } // --------------------------------------------------------------------------- // Scheduler trigger handlers // --------------------------------------------------------------------------- func (h *Handler) TriggerAWJob(w http.ResponseWriter, r *http.Request) { jobName := r.PathValue("job_name") if err := h.sched.TriggerAWJob(jobName); err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusAccepted, map[string]string{"status": "triggered", "job": jobName}) } func (h *Handler) TriggerWWIJob(w http.ResponseWriter, r *http.Request) { jobName := r.PathValue("job_name") if err := h.sched.TriggerWWIJob(jobName); err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusAccepted, map[string]string{"status": "triggered", "job": jobName}) }