package routes import ( "bssapp-backend/auth" "bssapp-backend/queries" "context" "encoding/json" "errors" "log" "net/http" "strconv" "strings" "time" ) // GET /api/pricing/products func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { started := time.Now() traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { log.Printf("[ProductPricing] trace=%s unauthorized method=%s path=%s", traceID, r.Method, r.URL.Path) http.Error(w, "unauthorized", http.StatusUnauthorized) return } log.Printf("[ProductPricing] trace=%s start user=%s id=%d", traceID, claims.Username, claims.ID) // Cloudflare upstream timeout is lower than 180s; fail fast and return API 504 instead of CDN 524. ctx, cancel := context.WithTimeout(r.Context(), 110*time.Second) defer cancel() limit := 500 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 500 { limit = parsed } } page := 1 if raw := strings.TrimSpace(r.URL.Query().Get("page")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { page = parsed } } filters := queries.ProductPricingFilters{ Search: strings.TrimSpace(r.URL.Query().Get("q")), ProductCode: splitCSVParam(r.URL.Query().Get("product_code")), BrandGroup: splitCSVParam(r.URL.Query().Get("brand_group_selection")), AskiliYan: splitCSVParam(r.URL.Query().Get("askili_yan")), Kategori: splitCSVParam(r.URL.Query().Get("kategori")), UrunIlkGrubu: splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")), UrunAnaGrubu: splitCSVParam(r.URL.Query().Get("urun_ana_grubu")), UrunAltGrubu: splitCSVParam(r.URL.Query().Get("urun_alt_grubu")), Icerik: splitCSVParam(r.URL.Query().Get("icerik")), Karisim: splitCSVParam(r.URL.Query().Get("karisim")), Marka: splitCSVParam(r.URL.Query().Get("marka")), } if len(filters.UrunAnaGrubu) > 3 { http.Error(w, "Urun Ana Grubu en fazla 3 secilebilir", http.StatusBadRequest) return } includeTotal := true if raw := strings.TrimSpace(r.URL.Query().Get("include_total")); raw != "" { if raw == "0" || strings.EqualFold(raw, "false") { includeTotal = false } } // When primary group filters are present, COUNT(*) is acceptable and improves UX // (accurate totalCount/totalPages). Force includeTotal on. if len(filters.UrunIlkGrubu) > 0 || len(filters.UrunAnaGrubu) > 0 { includeTotal = true } sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by")) desc := true if raw := strings.TrimSpace(r.URL.Query().Get("desc")); raw != "" { if raw == "0" || strings.EqualFold(raw, "false") { desc = false } } pageResult, err := queries.GetProductPricingPage(ctx, page, limit, filters, includeTotal, sortBy, desc) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { log.Printf( "[ProductPricing] trace=%s timeout user=%s id=%d duration_ms=%d err=%v", traceID, claims.Username, claims.ID, time.Since(started).Milliseconds(), err, ) http.Error(w, "Urun fiyatlandirma listesi zaman asimina ugradi", http.StatusGatewayTimeout) return } log.Printf( "[ProductPricing] trace=%s query_error user=%s id=%d duration_ms=%d err=%v", traceID, claims.Username, claims.ID, time.Since(started).Milliseconds(), err, ) http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError) return } log.Printf( "[ProductPricing] trace=%s success user=%s id=%d page=%d limit=%d count=%d total=%d total_pages=%d duration_ms=%d", traceID, claims.Username, claims.ID, pageResult.Page, limit, len(pageResult.Rows), pageResult.TotalCount, pageResult.TotalPages, time.Since(started).Milliseconds(), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("X-Total-Count", strconv.Itoa(pageResult.TotalCount)) w.Header().Set("X-Total-Pages", strconv.Itoa(pageResult.TotalPages)) w.Header().Set("X-Page", strconv.Itoa(pageResult.Page)) _ = json.NewEncoder(w).Encode(pageResult.Rows) } // GET /api/pricing/products/options?field=urunAnaGrubu&q=SER&limit=120 func GetProductPricingFilterOptionsHandler(w http.ResponseWriter, r *http.Request) { started := time.Now() traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { log.Printf("[ProductPricingOptions] trace=%s unauthorized method=%s path=%s", traceID, r.Method, r.URL.Path) http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() field := strings.TrimSpace(r.URL.Query().Get("field")) q := strings.TrimSpace(r.URL.Query().Get("q")) scopeUrunIlkGrubu := splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")) limit := 120 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 200 { limit = parsed } } items, err := queries.GetProductPricingFilterOptions(ctx, field, q, limit, scopeUrunIlkGrubu) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { log.Printf( "[ProductPricingOptions] trace=%s timeout user=%s id=%d field=%s q=%s duration_ms=%d err=%v", traceID, claims.Username, claims.ID, field, q, time.Since(started).Milliseconds(), err, ) http.Error(w, "Urun fiyatlandirma filtre secenekleri zaman asimina ugradi", http.StatusGatewayTimeout) return } log.Printf( "[ProductPricingOptions] trace=%s query_error user=%s id=%d field=%s q=%s duration_ms=%d err=%v", traceID, claims.Username, claims.ID, field, q, time.Since(started).Milliseconds(), err, ) http.Error(w, "Filtre secenekleri alinamadi: "+err.Error(), http.StatusInternalServerError) return } type optionItem struct { Label string `json:"label"` Value string `json:"value"` } resp := struct { Field string `json:"field"` Count int `json:"count"` Items []optionItem `json:"items"` }{ Field: field, Count: len(items), Items: make([]optionItem, 0, len(items)), } for _, v := range items { resp.Items = append(resp.Items, optionItem{Label: v, Value: v}) } log.Printf( "[ProductPricingOptions] trace=%s success user=%s id=%d field=%s q=%s count=%d duration_ms=%d", traceID, claims.Username, claims.ID, field, q, len(items), time.Since(started).Milliseconds(), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(resp) } func buildPricingTraceID(r *http.Request) string { if r != nil { if id := strings.TrimSpace(r.Header.Get("X-Request-ID")); id != "" { return id } if id := strings.TrimSpace(r.Header.Get("X-Correlation-ID")); id != "" { return id } } return "pricing-" + strconv.FormatInt(time.Now().UnixNano(), 36) } func isPricingTimeoutLike(err error, ctxErr error) bool { if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctxErr, context.DeadlineExceeded) { return true } if err == nil { return false } e := strings.ToLower(err.Error()) return strings.Contains(e, "timeout") || strings.Contains(e, "i/o timeout") || strings.Contains(e, "wsarecv") || strings.Contains(e, "connection attempt failed") || strings.Contains(e, "no connection could be made") || strings.Contains(e, "failed to respond") } func splitCSVParam(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } out = append(out, p) } return out }