Initial commit including .env
This commit is contained in:
472
controllers/doormilePricingController.go
Normal file
472
controllers/doormilePricingController.go
Normal file
@@ -0,0 +1,472 @@
|
||||
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"},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user