feat(barcode): admin barcode CRUD endpoints + UI; show extra barcodes publicly
Wire the multi-barcode store layer to HTTP and the operator console:
- adminhandler: add POST /products/{id}/barcodes, DELETE
/products/{id}/barcodes/{barcodeID}, and POST .../primary. A barcode
owned by another product returns 409 with the conflicting product
(gtin/product_id/product_name); an invalid GTIN returns 400.
- admin-frontend: BarcodesCard on the product detail page lists all
barcodes (primary starred), adds with type/pack-level/region, sets
primary, and deletes; audit labels for the new actions.
- public-frontend: product detail surfaces non-primary barcodes so a
case/region code resolves and is visible to consumers.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/adminstore"
|
||||
"github.com/baicai2026-baicai/goods/api/internal/auth"
|
||||
"github.com/baicai2026-baicai/goods/api/internal/gtin"
|
||||
)
|
||||
|
||||
// Handler holds the admin dependencies.
|
||||
@@ -62,6 +63,9 @@ func (h *Handler) Router() http.Handler {
|
||||
r.Delete("/api/products/{id}/images/{imageID}", h.DeleteImage)
|
||||
r.Post("/api/products/{id}/msrp", h.AddMSRP)
|
||||
r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP)
|
||||
r.Post("/api/products/{id}/barcodes", h.AddBarcode)
|
||||
r.Delete("/api/products/{id}/barcodes/{barcodeID}", h.DeleteBarcode)
|
||||
r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode)
|
||||
r.Get("/api/brands", h.ListBrands)
|
||||
r.Get("/api/categories", h.ListCategories)
|
||||
|
||||
@@ -231,6 +235,68 @@ func (h *Handler) DeleteMSRP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ---------- barcodes ----------
|
||||
|
||||
// AddBarcode validates and attaches a barcode to a product. A code already
|
||||
// owned by another product yields 409 with the conflicting product so the
|
||||
// operator can de-duplicate; an invalid GTIN yields 400.
|
||||
func (h *Handler) AddBarcode(w http.ResponseWriter, r *http.Request) {
|
||||
var in adminstore.BarcodeInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
|
||||
return
|
||||
}
|
||||
b, err := h.store.AddBarcode(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in)
|
||||
if h.handleBarcodeErr(w, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, b)
|
||||
}
|
||||
|
||||
// DeleteBarcode removes a barcode; a primary one is replaced automatically.
|
||||
func (h *Handler) DeleteBarcode(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.store.DeleteBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context()))
|
||||
if h.handleErr(w, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin.
|
||||
func (h *Handler) SetPrimaryBarcode(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := h.store.SetPrimaryBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context()))
|
||||
if h.handleErr(w, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// handleBarcodeErr maps barcode-specific errors (GTIN validation, ownership
|
||||
// conflict) to client-facing statuses, falling back to handleErr otherwise.
|
||||
func (h *Handler) handleBarcodeErr(w http.ResponseWriter, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var conflict *adminstore.ConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": map[string]string{"code": "barcode_conflict", "message": err.Error()},
|
||||
"conflict": map[string]string{
|
||||
"gtin": conflict.GTIN,
|
||||
"product_id": conflict.ProductID,
|
||||
"product_name": conflict.ProductName,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, gtin.ErrEmpty) || errors.Is(err, gtin.ErrFormat) ||
|
||||
errors.Is(err, gtin.ErrCheck) || errors.Is(err, gtin.ErrRestricted) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_gtin", err.Error())
|
||||
return true
|
||||
}
|
||||
return h.handleErr(w, err)
|
||||
}
|
||||
|
||||
// ListBrands returns brand options.
|
||||
func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.store.ListBrands(r.Context())
|
||||
|
||||
Reference in New Issue
Block a user