Files
apskel-pos-backend/internal/validator/product_validator.go
efrilmandClaude Opus 5 ebf666c004 feat(product): require a unit for weight-based products
A product sold by weight with no unit produces order lines with nothing to
print: the receipt would read "4,2" with no idea of what. Until now nothing
stopped that — the mistake only surfaced at the cashier.

Enforce it in two places, because neither alone sees the whole picture. On
create, the validator has everything it needs. On update, the request may
omit unit_id for a product that already has one, so the check runs in the
processor against the merged product: what is rejected is the end state, a
product sold by weight with no unit.

Also fixes two things this uncovered:

The struct tags on the product contracts are decorative — this validator is
hand-written and never calls validator.Struct — so `oneof=unit weight` was
never enforced, and an unknown sell_by was silently rewritten to "unit" by
the mapper. It is now rejected with a message that names the valid values.

The update validator's "at least one field" guard did not list unit_id,
sell_by or print_to_checker, so an update carrying only one of those was
turned away as an empty request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:29:34 +07:00

178 lines
6.0 KiB
Go

package validator
import (
"errors"
"strings"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"github.com/google/uuid"
)
type ProductValidator interface {
ValidateCreateProductRequest(req *contract.CreateProductRequest) (error, string)
ValidateUpdateProductRequest(req *contract.UpdateProductRequest) (error, string)
ValidateListProductsRequest(req *contract.ListProductsRequest) (error, string)
}
type ProductValidatorImpl struct{}
func NewProductValidator() *ProductValidatorImpl {
return &ProductValidatorImpl{}
}
func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.CreateProductRequest) (error, string) {
if req == nil {
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
if req.CategoryID == uuid.Nil {
return errors.New("category_id is required"), constants.MissingFieldErrorCode
}
if strings.TrimSpace(req.Name) == "" {
return errors.New("name is required"), constants.MissingFieldErrorCode
}
if len(req.Name) < 1 || len(req.Name) > 255 {
return errors.New("name must be between 1 and 255 characters"), constants.MalformedFieldErrorCode
}
if req.Price < 0 {
return errors.New("price must be non-negative"), constants.MalformedFieldErrorCode
}
if req.Cost != nil && *req.Cost < 0 {
return errors.New("cost must be non-negative"), constants.MalformedFieldErrorCode
}
if req.SKU != nil && len(*req.SKU) > 100 {
return errors.New("sku cannot exceed 100 characters"), constants.MalformedFieldErrorCode
}
if req.Description != nil && len(*req.Description) > 1000 {
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
}
if req.ImageURL != nil && len(*req.ImageURL) > 500 {
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
}
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
return err, code
}
return nil, ""
}
// validateSellBy checks how a product is sold and that it carries what that choice
// needs. A weight-based product without a unit would produce order lines with no unit
// to print, so the receipt could show "4,2" with no idea of what.
//
// unitID is the unit the request would leave on the product: for an update that does
// not touch unit_id, pass the product's current one.
func validateSellBy(sellBy *string, unitID *uuid.UUID) (error, string) {
if sellBy == nil {
return nil, ""
}
if !constants.IsValidSellBy(*sellBy) {
return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
}
if *sellBy == constants.SellByWeight && unitID == nil {
return errors.New("unit_id is required when sell_by is 'weight'"), constants.MissingFieldErrorCode
}
return nil, ""
}
func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.UpdateProductRequest) (error, string) {
if req == nil {
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
// At least one field should be provided for update
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" {
return errors.New("name cannot be empty"), constants.MalformedFieldErrorCode
}
if len(*req.Name) < 1 || len(*req.Name) > 255 {
return errors.New("name must be between 1 and 255 characters"), constants.MalformedFieldErrorCode
}
}
if req.Price != nil && *req.Price < 0 {
return errors.New("price must be non-negative"), constants.MalformedFieldErrorCode
}
if req.Cost != nil && *req.Cost < 0 {
return errors.New("cost must be non-negative"), constants.MalformedFieldErrorCode
}
if req.SKU != nil && len(*req.SKU) > 100 {
return errors.New("sku cannot exceed 100 characters"), constants.MalformedFieldErrorCode
}
if req.Description != nil && len(*req.Description) > 1000 {
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
}
if req.ImageURL != nil && len(*req.ImageURL) > 500 {
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
}
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
// Only the value is checked here. Whether the product ends up with a unit depends on
// what it already has, which this request cannot see — the processor checks that
// against the stored product.
if req.SellBy != nil && !constants.IsValidSellBy(*req.SellBy) {
return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
}
return nil, ""
}
func (v *ProductValidatorImpl) ValidateListProductsRequest(req *contract.ListProductsRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.MissingFieldErrorCode
}
if req.Page < 1 {
return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode
}
if req.Limit < 1 || req.Limit > 100 {
return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode
}
if req.MinPrice != nil && *req.MinPrice < 0 {
return errors.New("min_price must be non-negative"), constants.MalformedFieldErrorCode
}
if req.MaxPrice != nil && *req.MaxPrice < 0 {
return errors.New("max_price must be non-negative"), constants.MalformedFieldErrorCode
}
if req.MinPrice != nil && req.MaxPrice != nil && *req.MinPrice > *req.MaxPrice {
return errors.New("min_price cannot be greater than max_price"), constants.MalformedFieldErrorCode
}
return nil, ""
}