Initial commit including .env

This commit is contained in:
2026-06-22 17:43:40 +05:30
commit c577d47b75
286 changed files with 85878 additions and 0 deletions

View 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"},
},
})
}