diff --git a/backend/cmd/seeder/main.go b/backend/cmd/seeder/main.go new file mode 100644 index 0000000..47be53c --- /dev/null +++ b/backend/cmd/seeder/main.go @@ -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)) +} diff --git a/backend/go.mod b/backend/go.mod index 4035293..1be4d9f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -22,6 +22,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/mailru/easyjson v0.7.6 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect diff --git a/backend/go.sum b/backend/go.sum index 5b3fc73..a9e0b59 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 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/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/backend/internal/domain/distance.go b/backend/internal/domain/distance.go new file mode 100644 index 0000000..b591c5b --- /dev/null +++ b/backend/internal/domain/distance.go @@ -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"` +} diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index be0628a..82af30f 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -6,18 +6,26 @@ import ( "github.com/gofrs/uuid/v5" ) -// Company represents a B2B actor in the marketplace. -type Company struct { +// Tenant represents a B2B actor (pharmacy/distributor) in the marketplace. +type Tenant struct { ID uuid.UUID `db:"id" json:"id"` - Role string `db:"role" json:"role"` // pharmacy, distributor, admin CNPJ string `db:"cnpj" json:"cnpj"` CorporateName string `db:"corporate_name" json:"corporate_name"` + Category string `db:"category" json:"category"` // farmacia, distribuidora LicenseNumber string `db:"license_number" json:"license_number"` IsVerified bool `db:"is_verified" json:"is_verified"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // Location + 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. type User struct { ID uuid.UUID `db:"id" json:"id"` @@ -104,22 +112,30 @@ type ProductPage struct { PageSize int `json:"page_size"` } -// CompanyFilter captures company listing constraints. +// CompanyFilter captures company/tenant listing constraints. type CompanyFilter struct { - Role string - Search string - Limit int - Offset int + Category string + Search string + City string + 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 { - Companies []Company `json:"companies"` + Companies []Company `json:"tenants"` Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } +// TenantPage is an alias for CompanyPage. +type TenantPage = CompanyPage + // OrderFilter captures order listing constraints. type OrderFilter struct { BuyerID *uuid.UUID diff --git a/backend/internal/http/handler/company_handler.go b/backend/internal/http/handler/company_handler.go index f199112..d0ea92c 100644 --- a/backend/internal/http/handler/company_handler.go +++ b/backend/internal/http/handler/company_handler.go @@ -26,10 +26,14 @@ func (h *Handler) CreateCompany(w http.ResponseWriter, r *http.Request) { } company := &domain.Company{ - Role: req.Role, + Category: req.Category, CNPJ: req.CNPJ, CorporateName: req.CorporateName, 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 { @@ -49,8 +53,10 @@ func (h *Handler) CreateCompany(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListCompanies(w http.ResponseWriter, r *http.Request) { page, pageSize := parsePagination(r) filter := domain.CompanyFilter{ - Role: r.URL.Query().Get("role"), - Search: r.URL.Query().Get("search"), + Category: r.URL.Query().Get("category"), + 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) @@ -115,8 +121,8 @@ func (h *Handler) UpdateCompany(w http.ResponseWriter, r *http.Request) { return } - if req.Role != nil { - company.Role = *req.Role + if req.Category != nil { + company.Category = *req.Category } if req.CNPJ != nil { company.CNPJ = *req.CNPJ @@ -130,6 +136,18 @@ func (h *Handler) UpdateCompany(w http.ResponseWriter, r *http.Request) { if req.IsVerified != nil { 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 { writeError(w, http.StatusInternalServerError, err) diff --git a/backend/internal/http/handler/dto.go b/backend/internal/http/handler/dto.go index 16f0a36..0a53f80 100644 --- a/backend/internal/http/handler/dto.go +++ b/backend/internal/http/handler/dto.go @@ -33,10 +33,14 @@ type registerAuthRequest struct { type registerCompanyTarget struct { ID uuid.UUID `json:"id,omitempty"` - Role string `json:"role"` + Category string `json:"category"` CNPJ string `json:"cnpj"` CorporateName string `json:"corporate_name"` LicenseNumber string `json:"license_number"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + City string `json:"city"` + State string `json:"state"` } type loginRequest struct { @@ -80,18 +84,26 @@ type requester struct { } type registerCompanyRequest struct { - Role string `json:"role"` - CNPJ string `json:"cnpj"` - CorporateName string `json:"corporate_name"` - LicenseNumber string `json:"license_number"` + Category string `json:"category"` + CNPJ string `json:"cnpj"` + CorporateName string `json:"corporate_name"` + LicenseNumber string `json:"license_number"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + City string `json:"city"` + State string `json:"state"` } type updateCompanyRequest struct { - Role *string `json:"role,omitempty"` - CNPJ *string `json:"cnpj,omitempty"` - CorporateName *string `json:"corporate_name,omitempty"` - LicenseNumber *string `json:"license_number,omitempty"` - IsVerified *bool `json:"is_verified,omitempty"` + Category *string `json:"category,omitempty"` + CNPJ *string `json:"cnpj,omitempty"` + CorporateName *string `json:"corporate_name,omitempty"` + LicenseNumber *string `json:"license_number,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 { diff --git a/backend/internal/http/handler/handler.go b/backend/internal/http/handler/handler.go index 7df94c2..c126699 100644 --- a/backend/internal/http/handler/handler.go +++ b/backend/internal/http/handler/handler.go @@ -44,10 +44,14 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { if req.Company != nil { company = &domain.Company{ ID: req.Company.ID, - Role: req.Company.Role, + Category: req.Company.Category, CNPJ: req.Company.CNPJ, CorporateName: req.Company.CorporateName, LicenseNumber: req.Company.LicenseNumber, + Latitude: req.Company.Latitude, + Longitude: req.Company.Longitude, + City: req.Company.City, + State: req.Company.State, } } diff --git a/backend/internal/http/handler/handler_test.go b/backend/internal/http/handler/handler_test.go index 8a48d31..ab29617 100644 --- a/backend/internal/http/handler/handler_test.go +++ b/backend/internal/http/handler/handler_test.go @@ -127,6 +127,10 @@ func (m *MockRepository) ListInventory(ctx context.Context, filter domain.Invent 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 { id, _ := uuid.NewV4() order.ID = id @@ -282,7 +286,7 @@ func TestListCompanies(t *testing.T) { func TestCreateCompany(t *testing.T) { 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.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() diff --git a/backend/internal/http/handler/product_handler.go b/backend/internal/http/handler/product_handler.go index 7e58abc..9fe0082 100644 --- a/backend/internal/http/handler/product_handler.go +++ b/backend/internal/http/handler/product_handler.go @@ -62,6 +62,83 @@ func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) { 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 // @Summary Obter produto // @Tags Produtos diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 4ac8805..b1fe856 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -28,8 +28,8 @@ func (r *Repository) CreateCompany(ctx context.Context, company *domain.Company) company.CreatedAt = now company.UpdatedAt = now - query := `INSERT INTO companies (id, role, cnpj, corporate_name, license_number, is_verified, created_at, updated_at) -VALUES (: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, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :city, :state, :created_at, :updated_at)` _, err := r.db.NamedExecContext(ctx, query, company) return err @@ -40,14 +40,22 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil var args []any var clauses []string - if filter.Role != "" { - clauses = append(clauses, fmt.Sprintf("role = $%d", len(args)+1)) - args = append(args, filter.Role) + if filter.Category != "" { + clauses = append(clauses, fmt.Sprintf("category = $%d", len(args)+1)) + args = append(args, filter.Category) } if filter.Search != "" { clauses = append(clauses, fmt.Sprintf("(corporate_name ILIKE $%d OR cnpj ILIKE $%d)", len(args)+1, len(args)+1)) 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 := "" if len(clauses) > 0 { @@ -63,7 +71,7 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil filter.Limit = 20 } 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 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) { 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 { return nil, err } @@ -85,7 +93,7 @@ func (r *Repository) UpdateCompany(ctx context.Context, company *domain.Company) company.UpdatedAt = time.Now().UTC() 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` 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 } +// 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) { 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` @@ -860,15 +958,20 @@ func (r *Repository) InitSchema(ctx context.Context) error { schema := ` CREATE TABLE IF NOT EXISTS companies ( id UUID PRIMARY KEY, - role TEXT NOT NULL, cnpj TEXT NOT NULL UNIQUE, corporate_name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'farmacia', license_number TEXT NOT NULL, 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, updated_at TIMESTAMPTZ NOT NULL ); + CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, company_id UUID NOT NULL REFERENCES companies(id), diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 9915cfa..b6205b4 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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("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("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)) diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index 2ef2225..09187ca 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -24,6 +24,7 @@ type Repository interface { CreateProduct(ctx context.Context, product *domain.Product) 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) UpdateProduct(ctx context.Context, product *domain.Product) 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 } +// 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) { return s.repo.GetProduct(ctx, id) } diff --git a/backend/internal/usecase/usecase_test.go b/backend/internal/usecase/usecase_test.go index 49c6122..c760674 100644 --- a/backend/internal/usecase/usecase_test.go +++ b/backend/internal/usecase/usecase_test.go @@ -120,6 +120,10 @@ func (m *MockRepository) ListInventory(ctx context.Context, filter domain.Invent 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 func (m *MockRepository) CreateOrder(ctx context.Context, order *domain.Order) error { m.orders = append(m.orders, *order) @@ -276,7 +280,7 @@ func TestRegisterCompany(t *testing.T) { ctx := context.Background() company := &domain.Company{ - Role: "pharmacy", + Category: "farmacia", CNPJ: "12345678901234", CorporateName: "Test Pharmacy", LicenseNumber: "LIC-001", @@ -312,7 +316,7 @@ func TestVerifyCompany(t *testing.T) { company := &domain.Company{ ID: uuid.Must(uuid.NewV4()), - Role: "pharmacy", + Category: "farmacia", CNPJ: "12345678901234", CorporateName: "Test Pharmacy", IsVerified: false, @@ -759,7 +763,7 @@ func TestRegisterAccount(t *testing.T) { ctx := context.Background() company := &domain.Company{ - Role: "pharmacy", + Category: "farmacia", CNPJ: "12345678901234", CorporateName: "Test Pharmacy", }