package controllers import ( "context" "encoding/json" "fmt" "strconv" "time" "doormile/db" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) // Valid enum values var validZones = map[string]bool{ "Local": true, "Interstate": true, "OtherState": true, } var validCategories = map[string]bool{ "General": true, "Documents": true, "Electronics": true, "Clothing": true, "Fragile": true, "Medical": true, "Automotive": true, "Food": true, } var validServiceTypes = map[string]bool{ "Normal": true, "Express": true, } var categoryLabels = map[string]string{ "General": "General Goods", "Documents": "Books & Documents", "Electronics": "Electronics & Gadgets", "Clothing": "Clothing & Textiles", "Fragile": "Fragile Items", "Medical": "Medical & Pharma", "Automotive": "Automotive Parts", "Food": "Food & Perishables", } // pricingCacheKey returns the Redis key for a zone+servicetype slab. // Only 6 possible keys — 3 zones × 2 service types. func pricingCacheKey(zone, servicetype string) string { return fmt.Sprintf("doormile:pricing:%s:%s", zone, servicetype) } // invalidatePricingCache drops the Redis key for a zone+servicetype so the next // CheckPrice request re-warms it from Postgres. func invalidatePricingCache(zone, servicetype string) { if db.Rdb == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() key := pricingCacheKey(zone, servicetype) if err := db.Rdb.Del(ctx, key).Err(); err != nil { utils.Warn("Failed to invalidate pricing cache", "key", key, "error", err) } else { utils.Info("Pricing cache invalidated", "key", key) } } // loadFromPostgres fetches all Active rules for a zone+servicetype and writes // them into Redis. Returns the rules regardless of whether Redis succeeds. func loadFromPostgres(zone, servicetype string) ([]models.DoormilePricing, error) { var rules []models.DoormilePricing err := db.DB. Where("zone = ? AND servicetype = ? AND status = ? AND deletedat IS NULL", zone, servicetype, "Active"). Order("category ASC"). Find(&rules).Error if err != nil { return nil, err } // Warm the cache — no TTL because we invalidate explicitly on admin writes if db.Rdb != nil && len(rules) > 0 { data, merr := json.Marshal(rules) if merr == nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if rerr := db.Rdb.Set(ctx, pricingCacheKey(zone, servicetype), data, 0).Err(); rerr != nil { utils.Warn("Failed to warm pricing cache", "zone", zone, "servicetype", servicetype, "error", rerr) } else { utils.Info("Pricing cache warmed", "zone", zone, "servicetype", servicetype, "count", len(rules)) } } } return rules, nil } // applyFilters takes the full slab for a zone+servicetype and applies the // weight and optional category filters in memory. func applyFilters(rules []models.DoormilePricing, weight float64, category string) []models.DoormilePricing { out := make([]models.DoormilePricing, 0, len(rules)) for _, r := range rules { if r.Minweight > weight || r.Maxweight < weight { continue } if category != "" && r.Category != category { continue } out = append(out, r) } return out } // buildPriceResponse constructs the final JSON response from a filtered rule set. func buildPriceResponse(zone, servicetype string, weight float64, rules []models.DoormilePricing) fiber.Map { if len(rules) == 0 { return fiber.Map{ "found": false, "zone": zone, "service_type": servicetype, "weight": weight, "message": "no pricing configured for this combination", "results": []interface{}{}, "total": 0, } } results := make([]fiber.Map, 0, len(rules)) for _, r := range rules { results = append(results, fiber.Map{ "category": r.Category, "category_label": categoryLabels[r.Category], "min_price": r.Minprice, "max_price": r.Maxprice, }) } return fiber.Map{ "found": true, "zone": zone, "service_type": servicetype, "weight": weight, "currency": rules[0].Currency, "total": len(results), "results": results, } } // CheckPrice is the public endpoint Flutter calls after the customer fills the form. // Reads from Redis first (full zone+servicetype slab), filters by weight in memory. // Falls back to Postgres on cache miss and re-warms Redis before returning. // // POST /api/v1/pricing/check // Body: { zone, service_type, weight, category? } func CheckPrice(c *fiber.Ctx) error { type req struct { Zone string `json:"zone"` ServiceType string `json:"service_type"` Weight float64 `json:"weight"` Category string `json:"category"` } body := new(req) if err := c.BodyParser(body); err != nil { return utils.BadRequest(c, "invalid request body") } if !validZones[body.Zone] { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "success": false, "message": "invalid zone", "valid_zones": []string{"Local", "Interstate", "OtherState"}, }) } if !validServiceTypes[body.ServiceType] { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "success": false, "message": "invalid service_type", "valid_service_types": []string{"Normal", "Express"}, }) } if body.Weight <= 0 { return utils.BadRequest(c, "weight must be greater than 0") } if body.Category != "" && !validCategories[body.Category] { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "success": false, "message": "invalid category", "valid_categories": []string{"General", "Documents", "Electronics", "Clothing", "Fragile", "Medical", "Automotive", "Food"}, }) } var allRules []models.DoormilePricing if db.Rdb != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) cached, err := db.Rdb.Get(ctx, pricingCacheKey(body.Zone, body.ServiceType)).Result() cancel() if err == nil { if jerr := json.Unmarshal([]byte(cached), &allRules); jerr != nil { utils.Warn("Corrupt pricing cache entry, evicting", "zone", body.Zone, "servicetype", body.ServiceType) invalidatePricingCache(body.Zone, body.ServiceType) allRules = nil } } } if allRules == nil { var err error allRules, err = loadFromPostgres(body.Zone, body.ServiceType) if err != nil { return utils.Internal(c, "failed to fetch pricing") } } matched := applyFilters(allRules, body.Weight, body.Category) return c.JSON(fiber.Map{ "success": true, "data": buildPriceResponse(body.Zone, body.ServiceType, body.Weight, matched), }) } // GetDoormilePricing returns all pricing rules for admin review. // Always reads from Postgres — admins need the authoritative view. // GET /api/v1/admin/doormile-pricing func GetDoormilePricing(c *fiber.Ctx) error { query := db.DB.Where("deletedat IS NULL") if z := c.Query("zone"); z != "" { query = query.Where("zone = ?", z) } if cat := c.Query("category"); cat != "" { query = query.Where("category = ?", cat) } if st := c.Query("service_type"); st != "" { query = query.Where("servicetype = ?", st) } var rules []models.DoormilePricing if err := query.Order("zone, category, servicetype, min_weight").Find(&rules).Error; err != nil { return utils.Internal(c, "failed to fetch pricing rules") } return utils.List(c, rules, int64(len(rules))) } // CreateDoormilePricing adds a new pricing band and invalidates the relevant cache. // POST /api/v1/admin/doormile-pricing func CreateDoormilePricing(c *fiber.Ctx) error { type req struct { Zone string `json:"zone"` Category string `json:"category"` ServiceType string `json:"service_type"` MinWeight float64 `json:"min_weight"` MaxWeight float64 `json:"max_weight"` MinPrice float64 `json:"min_price"` MaxPrice float64 `json:"max_price"` Currency string `json:"currency"` } body := new(req) if err := c.BodyParser(body); err != nil { return utils.BadRequest(c, "invalid request body") } if !validZones[body.Zone] || !validCategories[body.Category] || !validServiceTypes[body.ServiceType] { return utils.BadRequest(c, "invalid zone, category, or service_type") } if body.MinWeight < 0 || body.MaxWeight <= body.MinWeight { return utils.BadRequest(c, "max_weight must be greater than min_weight") } if body.MinPrice <= 0 || body.MaxPrice <= body.MinPrice { return utils.BadRequest(c, "max_price must be greater than min_price") } currency := body.Currency if currency == "" { currency = "INR" } rule := models.DoormilePricing{ Zone: body.Zone, Category: body.Category, Servicetype: body.ServiceType, Minweight: body.MinWeight, Maxweight: body.MaxWeight, Minprice: body.MinPrice, Maxprice: body.MaxPrice, Currency: currency, Status: "Active", } if err := db.DB.Create(&rule).Error; err != nil { return utils.Internal(c, "failed to create pricing rule") } invalidatePricingCache(rule.Zone, rule.Servicetype) return utils.Created(c, rule) } // UpdateDoormilePricing updates a pricing band and invalidates affected cache keys. // If zone or service_type changes, both the old and new cache keys are invalidated. // PUT /api/v1/admin/doormile-pricing/:id func UpdateDoormilePricing(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid pricing rule ID") } var rule models.DoormilePricing if err := db.DB.Where("doormile_pricing_id = ? AND deletedat IS NULL", id).First(&rule).Error; err != nil { return utils.NotFound(c, "pricing rule not found") } // Capture old keys before any mutation oldZone := rule.Zone oldServicetype := rule.Servicetype type req struct { Zone string `json:"zone"` Category string `json:"category"` ServiceType string `json:"service_type"` MinWeight float64 `json:"min_weight"` MaxWeight float64 `json:"max_weight"` MinPrice float64 `json:"min_price"` MaxPrice float64 `json:"max_price"` Currency string `json:"currency"` Status string `json:"status"` } body := new(req) if err := c.BodyParser(body); err != nil { return utils.BadRequest(c, "invalid request body") } if body.Zone != "" { if !validZones[body.Zone] { return utils.BadRequest(c, "invalid zone") } rule.Zone = body.Zone } if body.Category != "" { if !validCategories[body.Category] { return utils.BadRequest(c, "invalid category") } rule.Category = body.Category } if body.ServiceType != "" { if !validServiceTypes[body.ServiceType] { return utils.BadRequest(c, "invalid service_type") } rule.Servicetype = body.ServiceType } if body.MinWeight >= 0 { rule.Minweight = body.MinWeight } if body.MaxWeight > 0 { rule.Maxweight = body.MaxWeight } if body.MinPrice > 0 { rule.Minprice = body.MinPrice } if body.MaxPrice > 0 { rule.Maxprice = body.MaxPrice } if body.Currency != "" { rule.Currency = body.Currency } if body.Status != "" { rule.Status = body.Status } rule.Updatedat = time.Now() if err := db.DB.Save(&rule).Error; err != nil { return utils.Internal(c, "failed to update pricing rule") } invalidatePricingCache(oldZone, oldServicetype) if rule.Zone != oldZone || rule.Servicetype != oldServicetype { invalidatePricingCache(rule.Zone, rule.Servicetype) } return utils.OK(c, rule) } // DeleteDoormilePricing soft-deletes a pricing band and invalidates its cache key. // DELETE /api/v1/admin/doormile-pricing/:id func DeleteDoormilePricing(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid pricing rule ID") } var rule models.DoormilePricing if err := db.DB.Where("doormile_pricing_id = ? AND deletedat IS NULL", id).First(&rule).Error; err != nil { return utils.NotFound(c, "pricing rule not found") } now := time.Now() rule.Deletedat = &now db.DB.Save(&rule) invalidatePricingCache(rule.Zone, rule.Servicetype) return utils.Message(c, "pricing rule deleted successfully") } // WarmPricingCache loads every Active pricing rule from Postgres into Redis on // server startup. Call this once from main.go after DB and Redis are ready. // If Redis is unavailable it logs a warning and returns — the lazy-load fallback // in CheckPrice will handle individual misses at request time. func WarmPricingCache() { if db.Rdb == nil { utils.Warn("WarmPricingCache: Redis not available, skipping pre-warm") return } utils.Info("WarmPricingCache: warming pricing cache from Postgres...") // Iterate every zone × servicetype combination zones := []string{"Local", "Interstate", "OtherState"} serviceTypes := []string{"Normal", "Express"} warmed := 0 for _, zone := range zones { for _, st := range serviceTypes { rules, err := loadFromPostgres(zone, st) if err != nil { utils.Error("WarmPricingCache: failed to load", "zone", zone, "servicetype", st, "error", err) continue } if len(rules) > 0 { warmed++ } } } utils.Info("WarmPricingCache: done", "slabs_warmed", warmed) } // GetPricingMeta returns the valid enum values so Flutter can populate its dropdowns. // GET /api/v1/pricing/meta func GetPricingMeta(c *fiber.Ctx) error { return utils.OK(c, fiber.Map{ "zones": []fiber.Map{ {"value": "Local", "label": "Local / Same City"}, {"value": "Interstate", "label": "Interstate"}, {"value": "OtherState", "label": "Other State"}, }, "categories": []fiber.Map{ {"value": "General", "label": "General Goods"}, {"value": "Documents", "label": "Books & Documents"}, {"value": "Electronics", "label": "Electronics & Gadgets"}, {"value": "Clothing", "label": "Clothing & Textiles"}, {"value": "Fragile", "label": "Fragile Items"}, {"value": "Medical", "label": "Medical & Pharma"}, {"value": "Automotive", "label": "Automotive Parts"}, {"value": "Food", "label": "Food & Perishables"}, }, "service_types": []fiber.Map{ {"value": "Normal", "label": "Normal"}, {"value": "Express", "label": "Express"}, }, }) }