diff --git a/controllers/productController.go b/controllers/productController.go index 3c816bb..a3e5cea 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -1008,3 +1008,44 @@ func (ctl *ProductController) RelinkCatalogue(c *fiber.Ctx) error { } return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report}) } + +// SetShowHealthScore turns one product's health score on or off for one shop. +// +// Scoped twice over: `middleware.WebAuth` refuses a request naming a tenant the +// session does not own — it reads `tenantid` from the body as well as the query +// — and the repository's UPDATE carries the tenant in its WHERE clause. A write +// that changes what a shopper sees should not rest on one guard being mounted +// correctly. +func (ctl *ProductController) SetShowHealthScore(c *fiber.Ctx) error { + var req struct { + Tenantid int `json:"tenantid"` + Productid int `json:"productid"` + // A POINTER so a body that forgot the field is refused rather than read + // as "turn it off". The whole point of this endpoint is the difference + // between the two. + Showhealthscore *bool `json:"showhealthscore"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, "status": false, "message": "Invalid request body", + }) + } + if req.Showhealthscore == nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, "status": false, + "message": "showhealthscore is required: send true or false.", + }) + } + + if err := ctl.productService.SetShowHealthScore(req.Tenantid, req.Productid, *req.Showhealthscore); err != nil { + // 409, not 500. "No such product for this business" is a fact the + // caller can act on, not a fault in the server. + return c.Status(http.StatusConflict).JSON(fiber.Map{ + "code": http.StatusConflict, "status": false, "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, "message": "Successfully Updated", + }) +} diff --git a/main.go b/main.go index 82f28fd..7cdb1a6 100644 --- a/main.go +++ b/main.go @@ -176,6 +176,26 @@ func main() { log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err) } + // Whether this shop shows a health score for this product. + // + // The shopkeeper's call, not ours. The score comes from a third party that + // matches a reference product by name — often at under 60% confidence — so a + // merchant who knows the packet in front of them may reasonably decide the + // rating does not describe what they are selling, and should be able to take + // it off their own shelf without taking it off everybody's. + // + // DEFAULT TRUE, so every product already imported keeps showing exactly what + // it shows today. A new column defaulting to false would silently strip the + // health score from every shelf on the platform, which is a change nobody + // asked for dressed up as a migration. + // + // Only the score. `nutrition` is unaffected and always sent: the figures are + // what the packet says, while the score is somebody's judgement of them. + if err := db.DB.Exec( + `ALTER TABLE products ADD COLUMN IF NOT EXISTS showhealthscore boolean NOT NULL DEFAULT true`).Error; err != nil { + log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err) + } + // When a product became visible to a store, and the only thing that decides // whether it is. // diff --git a/models/product.go b/models/product.go index 2e9284d..610ea0b 100644 --- a/models/product.go +++ b/models/product.go @@ -197,6 +197,25 @@ type Products struct { // models.IsEdible. Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"` + // Whether this shop shows a health score for this product. + // + // A real column, unlike the two above. The shopkeeper's call: the score + // comes from a third party matching a reference product by name, often + // under 60% confidence, and a merchant who knows the packet may reasonably + // decide the rating does not describe what they sell. + // + // Defaults true, so every product imported before this column existed keeps + // showing what it shows today. + // + // Gates `Healthscore` and NOTHING else. `Nutrition` is always sent — the + // figures are what the packet says, the score is a judgement of them, and a + // merchant turning off the judgement is not disputing the grams. + // + // The PLATFORM console ignores this: Nearle staff see every score on every + // product, because the decision being made there is whether the data is good + // enough to publish at all. + Showhealthscore bool `json:"showhealthscore" gorm:"column:showhealthscore;default:true"` + Productdesc string `json:"productdesc,omitempty"` Productsku string `json:"productsku,omitempty"` Brandid int `json:"brandid,omitempty"` @@ -492,6 +511,14 @@ type ImportCatalogueProductRequest struct { Retailprice float64 `json:"retailprice"` Productcost float64 `json:"productcost"` Taxpercent float64 `json:"taxpercent"` + + // Whether this shop will show the product's health score. + // + // A POINTER so "not sent" and "sent as false" stay different answers. Every + // caller that predates this field omits it, and a bare bool would read those + // as a deliberate no and strip the score from every import made by an older + // console. Nil means "they did not say", which is treated as yes. + Showhealthscore *bool `json:"showhealthscore"` } type Productlocations struct { diff --git a/repositories/productRepository.go b/repositories/productRepository.go index 378e057..0e3bd1d 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -24,6 +24,8 @@ type ProductRepository interface { GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) CreateProductStock(stocks []models.Productstock) error UpdateProductStatus(productIDs []int, status string) error + // SetShowHealthScore turns one product's health score on or off for one shop. + SetShowHealthScore(tenantID, productID int, show bool) error SyncProductLocationStatus(refs []models.ProductLocationRef) error EnsureProductLocation(refs []models.ProductLocationRef) error UpdateProduct(product models.Products) error @@ -1725,3 +1727,30 @@ func (r *productRepository) UpdateProductPricing(productid int, retailprice, pro "taxpercent": taxpercent, }).Error } + +// SetShowHealthScore turns one product's health score on or off for one shop. +// +// `Update` with a single column, deliberately, and not `Updates` with a struct. +// GORM's struct update SKIPS zero values, so `showhealthscore: false` would be +// silently dropped — the flag could be switched on and never off again, which is +// the exact failure a merchant would report as "it does not save". +// +// Scoped by tenant as well as product. `middleware.WebAuth` already refuses a +// request naming a tenant the session does not own, so this is the second lock +// rather than the first — but a write that changes what a shopper sees should +// not rest on one check being correctly mounted. +func (r *productRepository) SetShowHealthScore(tenantID, productID int, show bool) error { + result := r.db.Table("products"). + Where("productid = ? AND tenantid = ?", productID, tenantID). + Update("showhealthscore", show) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + // Either no such product, or one belonging to another business. Both + // are the same answer to the caller, and neither should look like it + // worked. + return fmt.Errorf("product %d was not found for this business", productID) + } + return nil +} diff --git a/routes/productroutes.go b/routes/productroutes.go index fb8ebdf..b899807 100644 --- a/routes/productroutes.go +++ b/routes/productroutes.go @@ -28,6 +28,14 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) { products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation) products.Post("/createproductlocation", f.ProductController.CreateProductLocation) products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct) + + // Whether this shop shows a product's health score. + // + // On `/v1/web` only. The merchant decides for their own shelf, so it needs + // the session that says which shelf is theirs — and the mobile group has no + // guard at all, which would make this "anyone can turn any shop's health + // scores off". + products.Put("/showhealthscore", f.ProductController.SetShowHealthScore) products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts) // Repairing catalogue links. A dry run unless `apply=true` — see the handler, diff --git a/services/productService.go b/services/productService.go index 56c8340..e21b955 100644 --- a/services/productService.go +++ b/services/productService.go @@ -33,6 +33,8 @@ type ProductService interface { GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error) + // SetShowHealthScore turns one product's health score on or off for one shop. + SetShowHealthScore(tenantID, productID int, show bool) error GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error) UpdateProductLocation(input models.Productlocations) error CreateProductLocation(input []models.Productlocations) error @@ -493,6 +495,10 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro Retailprice: retail, Taxpercent: req.Taxpercent, Approve: 1, + // Nil means the caller did not say, which is yes — see the + // field. An older console that knows nothing about this must not + // have its imports read as a deliberate no. + Showhealthscore: req.Showhealthscore == nil || *req.Showhealthscore, } // Everything the snapshot has no column for, kept as the catalogue // stated it. @@ -656,6 +662,31 @@ func decorateNutrition(nutrition NutritionService, products []models.Products) { // same upstream record. found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid) products[i].Nutrition = found.Panel - products[i].Healthscore = found.Health + + // The score is the shop's to show or not. The figures are not. + // + // A merchant turning this off is saying "that rating does not describe + // what I sell" — which is a judgement about a judgement, made by + // somebody holding the packet. It says nothing about the grams, so + // `Nutrition` above is set either way. + if products[i].Showhealthscore { + products[i].Healthscore = found.Health + } } } + +// SetShowHealthScore turns one product's health score on or off for one shop. +// +// The merchant's decision, not Nearle's. The score comes from a third party +// matching a reference product by name — frequently under 60% confidence — and +// a shopkeeper holding the packet is better placed than that matcher to say +// whether the rating describes what they sell. +// +// Only the score moves. The nutrition figures are what the label states and are +// sent either way; turning this off disputes the judgement, not the grams. +func (s *productService) SetShowHealthScore(tenantID, productID int, show bool) error { + if tenantID <= 0 || productID <= 0 { + return fmt.Errorf("tenantid and productid are both required") + } + return s.repo.SetShowHealthScore(tenantID, productID, show) +} diff --git a/services/showHealthScore_test.go b/services/showHealthScore_test.go new file mode 100644 index 0000000..a544262 --- /dev/null +++ b/services/showHealthScore_test.go @@ -0,0 +1,201 @@ +package services + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "nearle/models" + "nearle/repositories" +) + +/* +Whether a shop shows a product's health score. + +The score comes from a third party matching a reference product by name, often +at under 60% confidence, and the figures it is derived from are sometimes wrong +in ways a shopkeeper can see at a glance — one live record reports under 1mg of +sodium per 100g for salted crisps. So a merchant can take the rating off their +own shelf. + +Two rules run through all of this: + + - it hides the SCORE and never the nutrition. The figures are what the label + states; the score is somebody's judgement of them, and a merchant disputing + the judgement is not disputing the grams. + - silence means yes. Every product imported before this existed, and every + caller that does not know about it, must keep showing what they show today. +*/ + +func withScore(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/brands" { + _, _ = w.Write([]byte(`{"brands":["Balaji"]}`)) + return + } + _, _ = w.Write([]byte(balajiJSON)) + })) + t.Cleanup(server.Close) + return server +} + +func TestAShopThatShowsTheScoreGetsBoth(t *testing.T) { + rows := []models.Products{{ + Productid: 7101, Productbrand: "balaji", Imageid: "balaji_wafers_135g", + Showhealthscore: true, + }} + decorateNutrition(NewNutritionService(withScore(t).URL), rows) + + if rows[0].Healthscore == nil { + t.Fatal("the score was withheld from a shop that shows it") + } + if !rows[0].Nutrition.HasValues() { + t.Fatal("the figures went missing") + } +} + +func TestTurningTheScoreOffLeavesTheFiguresAlone(t *testing.T) { + // The whole shape of this feature. A merchant saying "that rating does not + // describe what I sell" has said nothing about the protein content. + rows := []models.Products{{ + Productid: 7101, Productbrand: "balaji", Imageid: "balaji_wafers_135g", + Showhealthscore: false, + }} + decorateNutrition(NewNutritionService(withScore(t).URL), rows) + + if rows[0].Healthscore != nil { + t.Fatalf("the score was shown by a shop that turned it off: %+v", rows[0].Healthscore) + } + if !rows[0].Nutrition.HasValues() { + t.Fatal("turning off the score also removed the nutrition figures") + } +} + +func TestTheHiddenScoreIsAbsentAndNotNull(t *testing.T) { + // Same contract as the rest of this endpoint: a missing key means "nothing + // to show", and the app already reads it that way. `"healthscore": null` + // would be a new shape for a client that is already written. + row := models.Products{Productid: 7101, Showhealthscore: false} + + encoded, err := json.Marshal(row) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(encoded, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, present := out["healthscore"]; present { + t.Fatalf("a hidden score was sent as a key: %s", encoded) + } +} + +func TestTwoShopsSellingTheSameProductDecideSeparately(t *testing.T) { + // The flag is on the tenant's product row, not the catalogue, so one shop + // hiding a rating cannot take it off anybody else's shelf. + rows := []models.Products{ + {Productid: 7101, Productbrand: "balaji", Imageid: "balaji_wafers_135g", Showhealthscore: true}, + {Productid: 8202, Productbrand: "balaji", Imageid: "balaji_wafers_135g", Showhealthscore: false}, + } + decorateNutrition(NewNutritionService(withScore(t).URL), rows) + + if rows[0].Healthscore == nil { + t.Error("the shop that shows it lost its score") + } + if rows[1].Healthscore != nil { + t.Error("the shop that hid it got one anyway") + } +} + +/* ── Silence means yes ───────────────────────────────────────────────────── */ + +func TestAnImportThatSaysNothingKeepsTheScore(t *testing.T) { + // Every console that predates this field omits it. Reading that as a + // deliberate "no" would strip the score from every import made by an older + // build — a change nobody asked for, arriving as a deploy. + var req models.ImportCatalogueProductRequest + if err := json.Unmarshal([]byte(`{"tenantid":1147,"productid":7101}`), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if req.Showhealthscore != nil { + t.Fatalf("an absent field was read as a value: %v", *req.Showhealthscore) + } + // This is the expression the import uses. + if show := req.Showhealthscore == nil || *req.Showhealthscore; !show { + t.Fatal("an import that said nothing turned the score off") + } +} + +func TestAnImportCanSayNoOutright(t *testing.T) { + // And the other half: `false` has to survive. A bool that cannot be set to + // false is a toggle that only switches on. + var req models.ImportCatalogueProductRequest + if err := json.Unmarshal([]byte(`{"showhealthscore":false}`), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if req.Showhealthscore == nil || *req.Showhealthscore { + t.Fatalf("an explicit no was lost: %v", req.Showhealthscore) + } + if show := req.Showhealthscore == nil || *req.Showhealthscore; show { + t.Fatal("an explicit no was read as yes") + } +} + +func TestAnImportCanSayYesOutright(t *testing.T) { + var req models.ImportCatalogueProductRequest + if err := json.Unmarshal([]byte(`{"showhealthscore":true}`), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if req.Showhealthscore == nil || !*req.Showhealthscore { + t.Fatalf("an explicit yes was lost: %v", req.Showhealthscore) + } +} + +/* ── The write ───────────────────────────────────────────────────────────── */ + +type showScoreRepo struct { + repositories.ProductRepository + tenantID, productID int + show bool + calls int +} + +func (r *showScoreRepo) SetShowHealthScore(tenantID, productID int, show bool) error { + r.calls++ + r.tenantID, r.productID, r.show = tenantID, productID, show + return nil +} + +func TestTurningItOffReachesTheRepositoryAsFalse(t *testing.T) { + // GORM's struct update skips zero values, so `false` is the value most + // likely to be silently dropped on its way to the database — which would + // read to a merchant as "it does not save". + repo := &showScoreRepo{} + service := &productService{repo: repo} + + if err := service.SetShowHealthScore(1147, 7101, false); err != nil { + t.Fatalf("SetShowHealthScore: %v", err) + } + if repo.calls != 1 || repo.tenantID != 1147 || repo.productID != 7101 { + t.Fatalf("wrong call: %+v", repo) + } + if repo.show { + t.Fatal("false arrived as true") + } +} + +func TestAWriteWithNoTenantIsRefusedBeforeItReachesTheDatabase(t *testing.T) { + // A tenantid of 0 in an UPDATE's WHERE clause matches nothing here, but the + // habit of letting one through is how an unscoped write eventually ships. + repo := &showScoreRepo{} + service := &productService{repo: repo} + + if err := service.SetShowHealthScore(0, 7101, false); err == nil { + t.Fatal("an unscoped write was accepted") + } + if repo.calls != 0 { + t.Fatal("it reached the repository anyway") + } +}