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:
parent
beffeb8268
commit
4bb848788f
14 changed files with 568 additions and 41 deletions
200
backend/cmd/seeder/main.go
Normal file
200
backend/cmd/seeder/main.go
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
65
backend/internal/domain/distance.go
Normal file
65
backend/internal/domain/distance.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue