feat: tenant model, seeder, and product search with distance

Tenant Model:
- Renamed Company→Tenant (Company alias for compatibility)
- Added: lat/lng, city, state, category
- Updated: postgres, handlers, DTOs, schema SQL

Seeder (cmd/seeder):
- Generates 400 pharmacies in Anápolis/GO
- 20-500 products per tenant
- Haversine distance variation ±5km from center

Product Search:
- GET /products/search with advanced filters
- Filters: price (min/max), expiration, distance
- Haversine distance calculation (approx km)
- Anonymous seller (only city/state shown until checkout)
- Ordered by expiration date (nearest first)

New domain types:
- ProductWithDistance, ProductSearchFilter, ProductSearchPage
- HaversineDistance function

Updated tests for Category instead of Role
This commit is contained in:
Tiago Yamamoto 2025-12-20 09:03:13 -03:00
parent beffeb8268
commit 4bb848788f
14 changed files with 568 additions and 41 deletions

200
backend/cmd/seeder/main.go Normal file
View file

@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"math/rand"
"os"
"time"
"github.com/gofrs/uuid/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv"
)
// Anápolis, GO coordinates
const (
AnapolisLat = -16.3281
AnapolisLng = -48.9530
VarianceKm = 5.0 // ~5km spread
)
// Product categories with sample medicines
var categories = []string{
"Analgésicos",
"Antibióticos",
"Anti-inflamatórios",
"Cardiovasculares",
"Dermatológicos",
"Vitaminas",
"Oftálmicos",
"Respiratórios",
}
var medicamentos = []struct {
Name string
Category string
BasePrice int64
}{
{"Dipirona 500mg", "Analgésicos", 1299},
{"Paracetamol 750mg", "Analgésicos", 899},
{"Ibuprofeno 400mg", "Anti-inflamatórios", 1599},
{"Nimesulida 100mg", "Anti-inflamatórios", 2499},
{"Amoxicilina 500mg", "Antibióticos", 3499},
{"Azitromicina 500mg", "Antibióticos", 4999},
{"Losartana 50mg", "Cardiovasculares", 2199},
{"Atenolol 50mg", "Cardiovasculares", 1899},
{"Vitamina C 1g", "Vitaminas", 1599},
{"Vitamina D 2000UI", "Vitaminas", 2299},
{"Cetoconazol Creme", "Dermatológicos", 2899},
{"Dexametasona Creme", "Dermatológicos", 1999},
{"Colírio Lubrificante", "Oftálmicos", 3299},
{"Lacrifilm 15ml", "Oftálmicos", 2899},
{"Loratadina 10mg", "Respiratórios", 1799},
{"Dexclorfeniramina 2mg", "Respiratórios", 1599},
{"Omeprazol 20mg", "Gastrointestinais", 1999},
{"Pantoprazol 40mg", "Gastrointestinais", 2999},
{"Metformina 850mg", "Antidiabéticos", 1299},
{"Glibenclamida 5mg", "Antidiabéticos", 999},
}
var pharmacyNames = []string{
"Farmácia Popular", "Drogaria Central", "Farma Vida", "Drogasil",
"Farmácia São João", "Droga Raia", "Ultrafarma", "Farma Bem",
"Drogaria Moderna", "Farmácia União", "Saúde Total", "Bem Estar",
"Vida Saudável", "Mais Saúde", "Farmácia do Povo", "Super Farma",
}
func main() {
godotenv.Load()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL not set")
}
db, err := sqlx.Connect("pgx", dsn)
if err != nil {
log.Fatalf("db connect: %v", err)
}
defer db.Close()
ctx := context.Background()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Generate 400 tenants
tenants := generateTenants(rng, 400)
log.Printf("Generated %d tenants", len(tenants))
// Insert tenants
for _, t := range tenants {
_, err := db.NamedExecContext(ctx, `
INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at)
VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :city, :state, :created_at, :updated_at)
ON CONFLICT (cnpj) DO NOTHING`, t)
if err != nil {
log.Printf("insert tenant %s: %v", t["corporate_name"], err)
}
}
log.Println("Tenants inserted")
// Generate and insert products for each tenant
totalProducts := 0
for _, t := range tenants {
tenantID := t["id"].(uuid.UUID)
numProducts := 20 + rng.Intn(481) // 20-500
products := generateProducts(rng, tenantID, numProducts)
totalProducts += len(products)
for _, p := range products {
_, err := db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
log.Printf("insert product: %v", err)
}
}
}
log.Printf("Inserted %d products total", totalProducts)
// Output summary
summary := map[string]interface{}{
"tenants": len(tenants),
"products": totalProducts,
"location": "Anápolis, GO",
}
out, _ := json.MarshalIndent(summary, "", " ")
fmt.Println(string(out))
}
func generateTenants(rng *rand.Rand, count int) []map[string]interface{} {
now := time.Now().UTC()
tenants := make([]map[string]interface{}, 0, count)
for i := 0; i < count; i++ {
id, _ := uuid.NewV4()
name := fmt.Sprintf("%s %d", pharmacyNames[rng.Intn(len(pharmacyNames))], i+1)
cnpj := generateCNPJ(rng)
// Vary lat/lng within ~5km of Anápolis center
latOffset := (rng.Float64() - 0.5) * 0.09 // ~5km
lngOffset := (rng.Float64() - 0.5) * 0.09
tenants = append(tenants, map[string]interface{}{
"id": id,
"cnpj": cnpj,
"corporate_name": name,
"category": "farmacia",
"license_number": fmt.Sprintf("CRF-GO-%05d", rng.Intn(99999)),
"is_verified": rng.Float32() > 0.3, // 70% verified
"latitude": AnapolisLat + latOffset,
"longitude": AnapolisLng + lngOffset,
"city": "Anápolis",
"state": "GO",
"created_at": now,
"updated_at": now,
})
}
return tenants
}
func generateProducts(rng *rand.Rand, sellerID uuid.UUID, count int) []map[string]interface{} {
now := time.Now().UTC()
products := make([]map[string]interface{}, 0, count)
for i := 0; i < count; i++ {
id, _ := uuid.NewV4()
med := medicamentos[rng.Intn(len(medicamentos))]
// Random expiration: 30 days to 2 years from now
daysToExpire := 30 + rng.Intn(700)
expiresAt := now.AddDate(0, 0, daysToExpire)
// Price variation: -20% to +30%
priceMultiplier := 0.8 + rng.Float64()*0.5
price := int64(float64(med.BasePrice) * priceMultiplier)
products = append(products, map[string]interface{}{
"id": id,
"seller_id": sellerID,
"name": med.Name,
"description": fmt.Sprintf("%s - %s", med.Name, med.Category),
"batch": fmt.Sprintf("L%d%03d", now.Year(), rng.Intn(999)),
"expires_at": expiresAt,
"price_cents": price,
"stock": int64(10 + rng.Intn(500)),
"created_at": now,
"updated_at": now,
})
}
return products
}
func generateCNPJ(rng *rand.Rand) string {
return fmt.Sprintf("%02d.%03d.%03d/%04d-%02d",
rng.Intn(99), rng.Intn(999), rng.Intn(999),
rng.Intn(9999)+1, rng.Intn(99))
}

View file

@ -22,6 +22,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.7.6 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect

View file

@ -33,6 +33,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=

View file

@ -0,0 +1,65 @@
package domain
import (
"math"
"time"
)
const earthRadiusKm = 6371.0
// HaversineDistance calculates the distance in kilometers between two points
// using the Haversine formula. The result is approximate (±5km for security).
func HaversineDistance(lat1, lng1, lat2, lng2 float64) float64 {
dLat := degreesToRadians(lat2 - lat1)
dLng := degreesToRadians(lng2 - lng1)
lat1Rad := degreesToRadians(lat1)
lat2Rad := degreesToRadians(lat2)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*
math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
distance := earthRadiusKm * c
// Round to nearest km for approximate distance (security)
return math.Round(distance)
}
func degreesToRadians(degrees float64) float64 {
return degrees * math.Pi / 180
}
// ProductWithDistance extends Product with distance information for search results.
type ProductWithDistance struct {
Product
DistanceKm float64 `json:"distance_km"`
TenantCity string `json:"tenant_city,omitempty"`
TenantState string `json:"tenant_state,omitempty"`
// TenantID is hidden for anonymous browsing, revealed only at checkout
}
// ProductSearchFilter captures search constraints.
type ProductSearchFilter struct {
Search string
Category string
MinPriceCents *int64
MaxPriceCents *int64
ExpiresAfter *time.Time
ExpiresBefore *time.Time
MaxDistanceKm *float64
BuyerLat float64
BuyerLng float64
Limit int
Offset int
}
// ProductSearchPage wraps search results with pagination.
type ProductSearchPage struct {
Products []ProductWithDistance `json:"products"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}

View file

@ -6,18 +6,26 @@ import (
"github.com/gofrs/uuid/v5" "github.com/gofrs/uuid/v5"
) )
// Company represents a B2B actor in the marketplace. // Tenant represents a B2B actor (pharmacy/distributor) in the marketplace.
type Company struct { type Tenant struct {
ID uuid.UUID `db:"id" json:"id"` ID uuid.UUID `db:"id" json:"id"`
Role string `db:"role" json:"role"` // pharmacy, distributor, admin
CNPJ string `db:"cnpj" json:"cnpj"` CNPJ string `db:"cnpj" json:"cnpj"`
CorporateName string `db:"corporate_name" json:"corporate_name"` CorporateName string `db:"corporate_name" json:"corporate_name"`
Category string `db:"category" json:"category"` // farmacia, distribuidora
LicenseNumber string `db:"license_number" json:"license_number"` LicenseNumber string `db:"license_number" json:"license_number"`
IsVerified bool `db:"is_verified" json:"is_verified"` IsVerified bool `db:"is_verified" json:"is_verified"`
CreatedAt time.Time `db:"created_at" json:"created_at"` // Location
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Latitude float64 `db:"latitude" json:"latitude"`
Longitude float64 `db:"longitude" json:"longitude"`
City string `db:"city" json:"city"`
State string `db:"state" json:"state"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }
// Company is an alias for Tenant for backward compatibility.
type Company = Tenant
// User represents an authenticated actor inside a company. // User represents an authenticated actor inside a company.
type User struct { type User struct {
ID uuid.UUID `db:"id" json:"id"` ID uuid.UUID `db:"id" json:"id"`
@ -104,22 +112,30 @@ type ProductPage struct {
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
} }
// CompanyFilter captures company listing constraints. // CompanyFilter captures company/tenant listing constraints.
type CompanyFilter struct { type CompanyFilter struct {
Role string Category string
Search string Search string
Limit int City string
Offset int State string
Limit int
Offset int
} }
// CompanyPage wraps paginated company results. // TenantFilter is an alias for CompanyFilter.
type TenantFilter = CompanyFilter
// CompanyPage wraps paginated company/tenant results.
type CompanyPage struct { type CompanyPage struct {
Companies []Company `json:"companies"` Companies []Company `json:"tenants"`
Total int64 `json:"total"` Total int64 `json:"total"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
} }
// TenantPage is an alias for CompanyPage.
type TenantPage = CompanyPage
// OrderFilter captures order listing constraints. // OrderFilter captures order listing constraints.
type OrderFilter struct { type OrderFilter struct {
BuyerID *uuid.UUID BuyerID *uuid.UUID

View file

@ -26,10 +26,14 @@ func (h *Handler) CreateCompany(w http.ResponseWriter, r *http.Request) {
} }
company := &domain.Company{ company := &domain.Company{
Role: req.Role, Category: req.Category,
CNPJ: req.CNPJ, CNPJ: req.CNPJ,
CorporateName: req.CorporateName, CorporateName: req.CorporateName,
LicenseNumber: req.LicenseNumber, LicenseNumber: req.LicenseNumber,
Latitude: req.Latitude,
Longitude: req.Longitude,
City: req.City,
State: req.State,
} }
if err := h.svc.RegisterCompany(r.Context(), company); err != nil { if err := h.svc.RegisterCompany(r.Context(), company); err != nil {
@ -49,8 +53,10 @@ func (h *Handler) CreateCompany(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ListCompanies(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListCompanies(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r) page, pageSize := parsePagination(r)
filter := domain.CompanyFilter{ filter := domain.CompanyFilter{
Role: r.URL.Query().Get("role"), Category: r.URL.Query().Get("category"),
Search: r.URL.Query().Get("search"), Search: r.URL.Query().Get("search"),
City: r.URL.Query().Get("city"),
State: r.URL.Query().Get("state"),
} }
result, err := h.svc.ListCompanies(r.Context(), filter, page, pageSize) result, err := h.svc.ListCompanies(r.Context(), filter, page, pageSize)
@ -115,8 +121,8 @@ func (h *Handler) UpdateCompany(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Role != nil { if req.Category != nil {
company.Role = *req.Role company.Category = *req.Category
} }
if req.CNPJ != nil { if req.CNPJ != nil {
company.CNPJ = *req.CNPJ company.CNPJ = *req.CNPJ
@ -130,6 +136,18 @@ func (h *Handler) UpdateCompany(w http.ResponseWriter, r *http.Request) {
if req.IsVerified != nil { if req.IsVerified != nil {
company.IsVerified = *req.IsVerified company.IsVerified = *req.IsVerified
} }
if req.Latitude != nil {
company.Latitude = *req.Latitude
}
if req.Longitude != nil {
company.Longitude = *req.Longitude
}
if req.City != nil {
company.City = *req.City
}
if req.State != nil {
company.State = *req.State
}
if err := h.svc.UpdateCompany(r.Context(), company); err != nil { if err := h.svc.UpdateCompany(r.Context(), company); err != nil {
writeError(w, http.StatusInternalServerError, err) writeError(w, http.StatusInternalServerError, err)

View file

@ -33,10 +33,14 @@ type registerAuthRequest struct {
type registerCompanyTarget struct { type registerCompanyTarget struct {
ID uuid.UUID `json:"id,omitempty"` ID uuid.UUID `json:"id,omitempty"`
Role string `json:"role"` Category string `json:"category"`
CNPJ string `json:"cnpj"` CNPJ string `json:"cnpj"`
CorporateName string `json:"corporate_name"` CorporateName string `json:"corporate_name"`
LicenseNumber string `json:"license_number"` LicenseNumber string `json:"license_number"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
City string `json:"city"`
State string `json:"state"`
} }
type loginRequest struct { type loginRequest struct {
@ -80,18 +84,26 @@ type requester struct {
} }
type registerCompanyRequest struct { type registerCompanyRequest struct {
Role string `json:"role"` Category string `json:"category"`
CNPJ string `json:"cnpj"` CNPJ string `json:"cnpj"`
CorporateName string `json:"corporate_name"` CorporateName string `json:"corporate_name"`
LicenseNumber string `json:"license_number"` LicenseNumber string `json:"license_number"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
City string `json:"city"`
State string `json:"state"`
} }
type updateCompanyRequest struct { type updateCompanyRequest struct {
Role *string `json:"role,omitempty"` Category *string `json:"category,omitempty"`
CNPJ *string `json:"cnpj,omitempty"` CNPJ *string `json:"cnpj,omitempty"`
CorporateName *string `json:"corporate_name,omitempty"` CorporateName *string `json:"corporate_name,omitempty"`
LicenseNumber *string `json:"license_number,omitempty"` LicenseNumber *string `json:"license_number,omitempty"`
IsVerified *bool `json:"is_verified,omitempty"` IsVerified *bool `json:"is_verified,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
City *string `json:"city,omitempty"`
State *string `json:"state,omitempty"`
} }
type registerProductRequest struct { type registerProductRequest struct {

View file

@ -44,10 +44,14 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
if req.Company != nil { if req.Company != nil {
company = &domain.Company{ company = &domain.Company{
ID: req.Company.ID, ID: req.Company.ID,
Role: req.Company.Role, Category: req.Company.Category,
CNPJ: req.Company.CNPJ, CNPJ: req.Company.CNPJ,
CorporateName: req.Company.CorporateName, CorporateName: req.Company.CorporateName,
LicenseNumber: req.Company.LicenseNumber, LicenseNumber: req.Company.LicenseNumber,
Latitude: req.Company.Latitude,
Longitude: req.Company.Longitude,
City: req.Company.City,
State: req.Company.State,
} }
} }

View file

@ -127,6 +127,10 @@ func (m *MockRepository) ListInventory(ctx context.Context, filter domain.Invent
return []domain.InventoryItem{}, 0, nil return []domain.InventoryItem{}, 0, nil
} }
func (m *MockRepository) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error) {
return []domain.ProductWithDistance{}, 0, nil
}
func (m *MockRepository) CreateOrder(ctx context.Context, order *domain.Order) error { func (m *MockRepository) CreateOrder(ctx context.Context, order *domain.Order) error {
id, _ := uuid.NewV4() id, _ := uuid.NewV4()
order.ID = id order.ID = id
@ -282,7 +286,7 @@ func TestListCompanies(t *testing.T) {
func TestCreateCompany(t *testing.T) { func TestCreateCompany(t *testing.T) {
h := newTestHandler() h := newTestHandler()
payload := `{"role":"pharmacy","cnpj":"12345678901234","corporate_name":"Test Pharmacy","license_number":"LIC-001"}` payload := `{"category":"farmacia","cnpj":"12345678901234","corporate_name":"Test Pharmacy","license_number":"LIC-001"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/companies", bytes.NewReader([]byte(payload))) req := httptest.NewRequest(http.MethodPost, "/api/v1/companies", bytes.NewReader([]byte(payload)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View file

@ -62,6 +62,83 @@ func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
// SearchProducts godoc
// @Summary Busca avançada de produtos com filtros e distância
// @Description Retorna produtos ordenados por validade, com distância aproximada. Vendedor anônimo até checkout.
// @Tags Produtos
// @Produce json
// @Param search query string false "Termo de busca"
// @Param min_price query integer false "Preço mínimo em centavos"
// @Param max_price query integer false "Preço máximo em centavos"
// @Param max_distance query number false "Distância máxima em km"
// @Param lat query number true "Latitude do comprador"
// @Param lng query number true "Longitude do comprador"
// @Param page query integer false "Página"
// @Param page_size query integer false "Itens por página"
// @Success 200 {object} domain.ProductSearchPage
// @Router /api/v1/products/search [get]
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r)
filter := domain.ProductSearchFilter{
Search: r.URL.Query().Get("search"),
}
// Parse buyer location (required)
latStr := r.URL.Query().Get("lat")
lngStr := r.URL.Query().Get("lng")
if latStr == "" || lngStr == "" {
writeError(w, http.StatusBadRequest, errors.New("lat and lng query params are required"))
return
}
lat, err := strconv.ParseFloat(latStr, 64)
if err != nil {
writeError(w, http.StatusBadRequest, errors.New("invalid lat value"))
return
}
lng, err := strconv.ParseFloat(lngStr, 64)
if err != nil {
writeError(w, http.StatusBadRequest, errors.New("invalid lng value"))
return
}
filter.BuyerLat = lat
filter.BuyerLng = lng
// Parse optional price filters
if v := r.URL.Query().Get("min_price"); v != "" {
if price, err := strconv.ParseInt(v, 10, 64); err == nil {
filter.MinPriceCents = &price
}
}
if v := r.URL.Query().Get("max_price"); v != "" {
if price, err := strconv.ParseInt(v, 10, 64); err == nil {
filter.MaxPriceCents = &price
}
}
// Parse optional max distance
if v := r.URL.Query().Get("max_distance"); v != "" {
if dist, err := strconv.ParseFloat(v, 64); err == nil {
filter.MaxDistanceKm = &dist
}
}
// Parse optional expiration filter
if v := r.URL.Query().Get("expires_before"); v != "" {
if days, err := strconv.Atoi(v); err == nil && days > 0 {
expires := time.Now().AddDate(0, 0, days)
filter.ExpiresBefore = &expires
}
}
result, err := h.svc.SearchProducts(r.Context(), filter, page, pageSize)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, result)
}
// GetProduct godoc // GetProduct godoc
// @Summary Obter produto // @Summary Obter produto
// @Tags Produtos // @Tags Produtos

View file

@ -28,8 +28,8 @@ func (r *Repository) CreateCompany(ctx context.Context, company *domain.Company)
company.CreatedAt = now company.CreatedAt = now
company.UpdatedAt = now company.UpdatedAt = now
query := `INSERT INTO companies (id, role, cnpj, corporate_name, license_number, is_verified, created_at, updated_at) query := `INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at)
VALUES (:id, :role, :cnpj, :corporate_name, :license_number, :is_verified, :created_at, :updated_at)` VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :city, :state, :created_at, :updated_at)`
_, err := r.db.NamedExecContext(ctx, query, company) _, err := r.db.NamedExecContext(ctx, query, company)
return err return err
@ -40,14 +40,22 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil
var args []any var args []any
var clauses []string var clauses []string
if filter.Role != "" { if filter.Category != "" {
clauses = append(clauses, fmt.Sprintf("role = $%d", len(args)+1)) clauses = append(clauses, fmt.Sprintf("category = $%d", len(args)+1))
args = append(args, filter.Role) args = append(args, filter.Category)
} }
if filter.Search != "" { if filter.Search != "" {
clauses = append(clauses, fmt.Sprintf("(corporate_name ILIKE $%d OR cnpj ILIKE $%d)", len(args)+1, len(args)+1)) clauses = append(clauses, fmt.Sprintf("(corporate_name ILIKE $%d OR cnpj ILIKE $%d)", len(args)+1, len(args)+1))
args = append(args, "%"+filter.Search+"%") args = append(args, "%"+filter.Search+"%")
} }
if filter.City != "" {
clauses = append(clauses, fmt.Sprintf("city = $%d", len(args)+1))
args = append(args, filter.City)
}
if filter.State != "" {
clauses = append(clauses, fmt.Sprintf("state = $%d", len(args)+1))
args = append(args, filter.State)
}
where := "" where := ""
if len(clauses) > 0 { if len(clauses) > 0 {
@ -63,7 +71,7 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil
filter.Limit = 20 filter.Limit = 20
} }
args = append(args, filter.Limit, filter.Offset) args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf("SELECT id, role, cnpj, corporate_name, license_number, is_verified, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args)) listQuery := fmt.Sprintf("SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
var companies []domain.Company var companies []domain.Company
if err := r.db.SelectContext(ctx, &companies, listQuery, args...); err != nil { if err := r.db.SelectContext(ctx, &companies, listQuery, args...); err != nil {
@ -74,7 +82,7 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil
func (r *Repository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) { func (r *Repository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
var company domain.Company var company domain.Company
query := `SELECT id, role, cnpj, corporate_name, license_number, is_verified, created_at, updated_at FROM companies WHERE id = $1` query := `SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at FROM companies WHERE id = $1`
if err := r.db.GetContext(ctx, &company, query, id); err != nil { if err := r.db.GetContext(ctx, &company, query, id); err != nil {
return nil, err return nil, err
} }
@ -85,7 +93,7 @@ func (r *Repository) UpdateCompany(ctx context.Context, company *domain.Company)
company.UpdatedAt = time.Now().UTC() company.UpdatedAt = time.Now().UTC()
query := `UPDATE companies query := `UPDATE companies
SET role = :role, cnpj = :cnpj, corporate_name = :corporate_name, license_number = :license_number, is_verified = :is_verified, updated_at = :updated_at SET cnpj = :cnpj, corporate_name = :corporate_name, category = :category, license_number = :license_number, is_verified = :is_verified, latitude = :latitude, longitude = :longitude, city = :city, state = :state, updated_at = :updated_at
WHERE id = :id` WHERE id = :id`
res, err := r.db.NamedExecContext(ctx, query, company) res, err := r.db.NamedExecContext(ctx, query, company)
@ -198,6 +206,96 @@ func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilt
return products, total, nil return products, total, nil
} }
// SearchProducts returns products with distance from buyer, ordered by expiration date.
// Tenant info is anonymized (only city/state shown, not company name/ID).
func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error) {
baseQuery := `FROM products p INNER JOIN companies c ON p.seller_id = c.id`
var args []any
var clauses []string
if filter.Search != "" {
clauses = append(clauses, fmt.Sprintf("p.name ILIKE $%d", len(args)+1))
args = append(args, "%"+filter.Search+"%")
}
if filter.MinPriceCents != nil {
clauses = append(clauses, fmt.Sprintf("p.price_cents >= $%d", len(args)+1))
args = append(args, *filter.MinPriceCents)
}
if filter.MaxPriceCents != nil {
clauses = append(clauses, fmt.Sprintf("p.price_cents <= $%d", len(args)+1))
args = append(args, *filter.MaxPriceCents)
}
if filter.ExpiresAfter != nil {
clauses = append(clauses, fmt.Sprintf("p.expires_at >= $%d", len(args)+1))
args = append(args, *filter.ExpiresAfter)
}
if filter.ExpiresBefore != nil {
clauses = append(clauses, fmt.Sprintf("p.expires_at <= $%d", len(args)+1))
args = append(args, *filter.ExpiresBefore)
}
// Always filter only available products
clauses = append(clauses, "p.stock > 0")
where := ""
if len(clauses) > 0 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
var total int64
countQuery := "SELECT count(*) " + baseQuery + where
if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil {
return nil, 0, err
}
if filter.Limit <= 0 {
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
// Select products with tenant location info (anonymous: no company name/ID)
listQuery := fmt.Sprintf(`
SELECT p.id, p.seller_id, p.name, p.description, p.batch, p.expires_at,
p.price_cents, p.stock, p.created_at, p.updated_at,
c.city, c.state, c.latitude, c.longitude
%s%s
ORDER BY p.expires_at ASC
LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args))
type productRow struct {
domain.Product
City string `db:"city"`
State string `db:"state"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
}
var rows []productRow
if err := r.db.SelectContext(ctx, &rows, listQuery, args...); err != nil {
return nil, 0, err
}
// Calculate distance and build response
results := make([]domain.ProductWithDistance, 0, len(rows))
for _, row := range rows {
dist := domain.HaversineDistance(filter.BuyerLat, filter.BuyerLng, row.Latitude, row.Longitude)
// Filter by max distance if specified
if filter.MaxDistanceKm != nil && dist > *filter.MaxDistanceKm {
continue
}
results = append(results, domain.ProductWithDistance{
Product: row.Product,
DistanceKm: dist,
TenantCity: row.City,
TenantState: row.State,
})
}
return results, total, nil
}
func (r *Repository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) { func (r *Repository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
var product domain.Product var product domain.Product
query := `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE id = $1` query := `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE id = $1`
@ -860,15 +958,20 @@ func (r *Repository) InitSchema(ctx context.Context) error {
schema := ` schema := `
CREATE TABLE IF NOT EXISTS companies ( CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
role TEXT NOT NULL,
cnpj TEXT NOT NULL UNIQUE, cnpj TEXT NOT NULL UNIQUE,
corporate_name TEXT NOT NULL, corporate_name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'farmacia',
license_number TEXT NOT NULL, license_number TEXT NOT NULL,
is_verified BOOLEAN NOT NULL DEFAULT FALSE, is_verified BOOLEAN NOT NULL DEFAULT FALSE,
latitude DOUBLE PRECISION NOT NULL DEFAULT 0,
longitude DOUBLE PRECISION NOT NULL DEFAULT 0,
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL updated_at TIMESTAMPTZ NOT NULL
); );
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id), company_id UUID NOT NULL REFERENCES companies(id),

View file

@ -73,7 +73,9 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("POST /api/v1/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip)) mux.Handle("GET /api/v1/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products/search", chain(http.HandlerFunc(h.SearchProducts), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products/{id}", chain(http.HandlerFunc(h.GetProduct), middleware.Logger, middleware.Gzip)) mux.Handle("GET /api/v1/products/{id}", chain(http.HandlerFunc(h.GetProduct), middleware.Logger, middleware.Gzip))
mux.Handle("PATCH /api/v1/products/{id}", chain(http.HandlerFunc(h.UpdateProduct), middleware.Logger, middleware.Gzip)) mux.Handle("PATCH /api/v1/products/{id}", chain(http.HandlerFunc(h.UpdateProduct), middleware.Logger, middleware.Gzip))
mux.Handle("DELETE /api/v1/products/{id}", chain(http.HandlerFunc(h.DeleteProduct), middleware.Logger, middleware.Gzip)) mux.Handle("DELETE /api/v1/products/{id}", chain(http.HandlerFunc(h.DeleteProduct), middleware.Logger, middleware.Gzip))

View file

@ -24,6 +24,7 @@ type Repository interface {
CreateProduct(ctx context.Context, product *domain.Product) error CreateProduct(ctx context.Context, product *domain.Product) error
ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error) ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error)
SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error)
GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error)
UpdateProduct(ctx context.Context, product *domain.Product) error UpdateProduct(ctx context.Context, product *domain.Product) error
DeleteProduct(ctx context.Context, id uuid.UUID) error DeleteProduct(ctx context.Context, id uuid.UUID) error
@ -136,6 +137,24 @@ func (s *Service) ListProducts(ctx context.Context, filter domain.ProductFilter,
return &domain.ProductPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil return &domain.ProductPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
} }
// SearchProducts returns products with distance, ordered by expiration date.
// Seller info is anonymized until checkout.
func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter, page, pageSize int) (*domain.ProductSearchPage, error) {
if pageSize <= 0 {
pageSize = 20
}
if page <= 0 {
page = 1
}
filter.Limit = pageSize
filter.Offset = (page - 1) * pageSize
products, total, err := s.repo.SearchProducts(ctx, filter)
if err != nil {
return nil, err
}
return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *Service) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) { func (s *Service) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
return s.repo.GetProduct(ctx, id) return s.repo.GetProduct(ctx, id)
} }

View file

@ -120,6 +120,10 @@ func (m *MockRepository) ListInventory(ctx context.Context, filter domain.Invent
return []domain.InventoryItem{}, 0, nil return []domain.InventoryItem{}, 0, nil
} }
func (m *MockRepository) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error) {
return []domain.ProductWithDistance{}, 0, nil
}
// Order methods // Order methods
func (m *MockRepository) CreateOrder(ctx context.Context, order *domain.Order) error { func (m *MockRepository) CreateOrder(ctx context.Context, order *domain.Order) error {
m.orders = append(m.orders, *order) m.orders = append(m.orders, *order)
@ -276,7 +280,7 @@ func TestRegisterCompany(t *testing.T) {
ctx := context.Background() ctx := context.Background()
company := &domain.Company{ company := &domain.Company{
Role: "pharmacy", Category: "farmacia",
CNPJ: "12345678901234", CNPJ: "12345678901234",
CorporateName: "Test Pharmacy", CorporateName: "Test Pharmacy",
LicenseNumber: "LIC-001", LicenseNumber: "LIC-001",
@ -312,7 +316,7 @@ func TestVerifyCompany(t *testing.T) {
company := &domain.Company{ company := &domain.Company{
ID: uuid.Must(uuid.NewV4()), ID: uuid.Must(uuid.NewV4()),
Role: "pharmacy", Category: "farmacia",
CNPJ: "12345678901234", CNPJ: "12345678901234",
CorporateName: "Test Pharmacy", CorporateName: "Test Pharmacy",
IsVerified: false, IsVerified: false,
@ -759,7 +763,7 @@ func TestRegisterAccount(t *testing.T) {
ctx := context.Background() ctx := context.Background()
company := &domain.Company{ company := &domain.Company{
Role: "pharmacy", Category: "farmacia",
CNPJ: "12345678901234", CNPJ: "12345678901234",
CorporateName: "Test Pharmacy", CorporateName: "Test Pharmacy",
} }