saveinmed/backend/internal/repository/postgres/postgres.go
Tiago Yamamoto 4bb848788f 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
2025-12-20 09:03:13 -03:00

1077 lines
35 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
"github.com/saveinmed/backend-go/internal/domain"
)
// Repository implements the data access layer using sqlx + pgx.
type Repository struct {
db *sqlx.DB
}
// New creates a Postgres-backed repository and configures pooling.
func New(db *sqlx.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) CreateCompany(ctx context.Context, company *domain.Company) error {
now := time.Now().UTC()
company.CreatedAt = now
company.UpdatedAt = now
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
}
func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFilter) ([]domain.Company, int64, error) {
baseQuery := `FROM companies`
var args []any
var clauses []string
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 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
var total int64
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil {
return nil, 0, err
}
if filter.Limit <= 0 {
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
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 {
return nil, 0, err
}
return companies, total, nil
}
func (r *Repository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
var company domain.Company
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
}
return &company, nil
}
func (r *Repository) UpdateCompany(ctx context.Context, company *domain.Company) error {
company.UpdatedAt = time.Now().UTC()
query := `UPDATE companies
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)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("company not found")
}
return nil
}
func (r *Repository) DeleteCompany(ctx context.Context, id uuid.UUID) error {
var count int
if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM users WHERE company_id = $1`, id); err != nil {
return err
}
if count > 0 {
return errors.New("company has related users")
}
if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM products WHERE seller_id = $1`, id); err != nil {
return err
}
if count > 0 {
return errors.New("company has related products")
}
if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM orders WHERE buyer_id = $1 OR seller_id = $1`, id); err != nil {
return err
}
if count > 0 {
return errors.New("company has related orders")
}
if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM reviews WHERE buyer_id = $1 OR seller_id = $1`, id); err != nil {
return err
}
if count > 0 {
return errors.New("company has related reviews")
}
if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM cart_items WHERE buyer_id = $1`, id); err != nil {
return err
}
if count > 0 {
return errors.New("company has related cart items")
}
res, err := r.db.ExecContext(ctx, `DELETE FROM companies WHERE id = $1`, id)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("company not found")
}
return nil
}
func (r *Repository) CreateProduct(ctx context.Context, product *domain.Product) error {
now := time.Now().UTC()
product.CreatedAt = now
product.UpdatedAt = now
query := `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)`
_, err := r.db.NamedExecContext(ctx, query, product)
return err
}
func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error) {
baseQuery := `FROM products`
var args []any
var clauses []string
if filter.SellerID != nil {
clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1))
args = append(args, *filter.SellerID)
}
if filter.Search != "" {
clauses = append(clauses, fmt.Sprintf("name ILIKE $%d", len(args)+1))
args = append(args, "%"+filter.Search+"%")
}
where := ""
if len(clauses) > 0 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
var total int64
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil {
return nil, 0, err
}
if filter.Limit <= 0 {
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf("SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
var products []domain.Product
if err := r.db.SelectContext(ctx, &products, listQuery, args...); err != nil {
return nil, 0, err
}
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`
if err := r.db.GetContext(ctx, &product, query, id); err != nil {
return nil, err
}
return &product, nil
}
func (r *Repository) UpdateProduct(ctx context.Context, product *domain.Product) error {
product.UpdatedAt = time.Now().UTC()
query := `UPDATE products
SET seller_id = :seller_id, name = :name, description = :description, batch = :batch, expires_at = :expires_at, price_cents = :price_cents, stock = :stock, updated_at = :updated_at
WHERE id = :id`
res, err := r.db.NamedExecContext(ctx, query, product)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("product not found")
}
return nil
}
func (r *Repository) DeleteProduct(ctx context.Context, id uuid.UUID) error {
var count int
if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM order_items WHERE product_id = $1`, id); err != nil {
return err
}
if count > 0 {
return errors.New("product has related orders")
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM inventory_adjustments WHERE product_id = $1`, id); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM cart_items WHERE product_id = $1`, id); err != nil {
_ = tx.Rollback()
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM products WHERE id = $1`, id)
if err != nil {
_ = tx.Rollback()
return err
}
rows, err := res.RowsAffected()
if err != nil {
_ = tx.Rollback()
return err
}
if rows == 0 {
_ = tx.Rollback()
return errors.New("product not found")
}
return tx.Commit()
}
func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error {
now := time.Now().UTC()
order.CreatedAt = now
order.UpdatedAt = now
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
orderQuery := `INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`
if _, err := tx.ExecContext(ctx, orderQuery, order.ID, order.BuyerID, order.SellerID, order.Status, order.TotalCents, order.Shipping.RecipientName, order.Shipping.Street, order.Shipping.Number, order.Shipping.Complement, order.Shipping.District, order.Shipping.City, order.Shipping.State, order.Shipping.ZipCode, order.Shipping.Country, order.CreatedAt, order.UpdatedAt); err != nil {
_ = tx.Rollback()
return err
}
itemQuery := `INSERT INTO order_items (id, order_id, product_id, quantity, unit_cents, batch, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`
for i := range order.Items {
item := &order.Items[i]
item.ID = uuid.Must(uuid.NewV7())
item.OrderID = order.ID
if _, err := tx.ExecContext(ctx, itemQuery, item.ID, item.OrderID, item.ProductID, item.Quantity, item.UnitCents, item.Batch, item.ExpiresAt); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (r *Repository) ListOrders(ctx context.Context, filter domain.OrderFilter) ([]domain.Order, int64, error) {
baseQuery := `FROM orders`
var args []any
var clauses []string
if filter.BuyerID != nil {
clauses = append(clauses, fmt.Sprintf("buyer_id = $%d", len(args)+1))
args = append(args, *filter.BuyerID)
}
if filter.SellerID != nil {
clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1))
args = append(args, *filter.SellerID)
}
if filter.Status != "" {
clauses = append(clauses, fmt.Sprintf("status = $%d", len(args)+1))
args = append(args, filter.Status)
}
where := ""
if len(clauses) > 0 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
var total int64
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil {
return nil, 0, err
}
if filter.Limit <= 0 {
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf(`SELECT id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args))
var rows []struct {
ID uuid.UUID `db:"id"`
BuyerID uuid.UUID `db:"buyer_id"`
SellerID uuid.UUID `db:"seller_id"`
Status domain.OrderStatus `db:"status"`
TotalCents int64 `db:"total_cents"`
ShippingRecipientName string `db:"shipping_recipient_name"`
ShippingStreet string `db:"shipping_street"`
ShippingNumber string `db:"shipping_number"`
ShippingComplement string `db:"shipping_complement"`
ShippingDistrict string `db:"shipping_district"`
ShippingCity string `db:"shipping_city"`
ShippingState string `db:"shipping_state"`
ShippingZipCode string `db:"shipping_zip_code"`
ShippingCountry string `db:"shipping_country"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
if err := r.db.SelectContext(ctx, &rows, listQuery, args...); err != nil {
return nil, 0, err
}
orders := make([]domain.Order, 0, len(rows))
itemQuery := `SELECT id, order_id, product_id, quantity, unit_cents, batch, expires_at FROM order_items WHERE order_id = $1`
for _, row := range rows {
var items []domain.OrderItem
if err := r.db.SelectContext(ctx, &items, itemQuery, row.ID); err != nil {
return nil, 0, err
}
orders = append(orders, domain.Order{
ID: row.ID,
BuyerID: row.BuyerID,
SellerID: row.SellerID,
Status: row.Status,
TotalCents: row.TotalCents,
Items: items,
Shipping: domain.ShippingAddress{
RecipientName: row.ShippingRecipientName,
Street: row.ShippingStreet,
Number: row.ShippingNumber,
Complement: row.ShippingComplement,
District: row.ShippingDistrict,
City: row.ShippingCity,
State: row.ShippingState,
ZipCode: row.ShippingZipCode,
Country: row.ShippingCountry,
},
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
return orders, total, nil
}
func (r *Repository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
var row struct {
ID uuid.UUID `db:"id"`
BuyerID uuid.UUID `db:"buyer_id"`
SellerID uuid.UUID `db:"seller_id"`
Status domain.OrderStatus `db:"status"`
TotalCents int64 `db:"total_cents"`
ShippingRecipientName string `db:"shipping_recipient_name"`
ShippingStreet string `db:"shipping_street"`
ShippingNumber string `db:"shipping_number"`
ShippingComplement string `db:"shipping_complement"`
ShippingDistrict string `db:"shipping_district"`
ShippingCity string `db:"shipping_city"`
ShippingState string `db:"shipping_state"`
ShippingZipCode string `db:"shipping_zip_code"`
ShippingCountry string `db:"shipping_country"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at FROM orders WHERE id = $1`
if err := r.db.GetContext(ctx, &row, orderQuery, id); err != nil {
return nil, err
}
var items []domain.OrderItem
itemQuery := `SELECT id, order_id, product_id, quantity, unit_cents, batch, expires_at FROM order_items WHERE order_id = $1`
if err := r.db.SelectContext(ctx, &items, itemQuery, id); err != nil {
return nil, err
}
order := &domain.Order{
ID: row.ID,
BuyerID: row.BuyerID,
SellerID: row.SellerID,
Status: row.Status,
TotalCents: row.TotalCents,
Items: items,
Shipping: domain.ShippingAddress{
RecipientName: row.ShippingRecipientName,
Street: row.ShippingStreet,
Number: row.ShippingNumber,
Complement: row.ShippingComplement,
District: row.ShippingDistrict,
City: row.ShippingCity,
State: row.ShippingState,
ZipCode: row.ShippingZipCode,
Country: row.ShippingCountry,
},
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
return order, nil
}
func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error {
query := `UPDATE orders SET status = $1, updated_at = $2 WHERE id = $3`
res, err := r.db.ExecContext(ctx, query, status, time.Now().UTC(), id)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("order not found")
}
return nil
}
func (r *Repository) DeleteOrder(ctx context.Context, id uuid.UUID) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM reviews WHERE order_id = $1`, id); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM shipments WHERE order_id = $1`, id); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM order_items WHERE order_id = $1`, id); err != nil {
_ = tx.Rollback()
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM orders WHERE id = $1`, id)
if err != nil {
_ = tx.Rollback()
return err
}
rows, err := res.RowsAffected()
if err != nil {
_ = tx.Rollback()
return err
}
if rows == 0 {
_ = tx.Rollback()
return errors.New("order not found")
}
return tx.Commit()
}
func (r *Repository) CreateShipment(ctx context.Context, shipment *domain.Shipment) error {
now := time.Now().UTC()
shipment.CreatedAt = now
shipment.UpdatedAt = now
query := `INSERT INTO shipments (id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at)
VALUES (:id, :order_id, :carrier, :tracking_code, :external_tracking, :status, :created_at, :updated_at)`
_, err := r.db.NamedExecContext(ctx, query, shipment)
return err
}
func (r *Repository) GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) {
var shipment domain.Shipment
query := `SELECT id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at FROM shipments WHERE order_id = $1`
if err := r.db.GetContext(ctx, &shipment, query, orderID); err != nil {
return nil, err
}
return &shipment, nil
}
func (r *Repository) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
var product domain.Product
if err := tx.GetContext(ctx, &product, `SELECT id, seller_id, name, batch, expires_at, price_cents, stock, updated_at FROM products WHERE id = $1 FOR UPDATE`, productID); err != nil {
_ = tx.Rollback()
return nil, err
}
newStock := product.Stock + delta
if newStock < 0 {
_ = tx.Rollback()
return nil, errors.New("inventory cannot be negative")
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx, `UPDATE products SET stock = $1, updated_at = $2 WHERE id = $3`, newStock, now, productID); err != nil {
_ = tx.Rollback()
return nil, err
}
adj := domain.InventoryAdjustment{
ID: uuid.Must(uuid.NewV7()),
ProductID: productID,
Delta: delta,
Reason: reason,
CreatedAt: now,
}
if _, err := tx.NamedExecContext(ctx, `INSERT INTO inventory_adjustments (id, product_id, delta, reason, created_at) VALUES (:id, :product_id, :delta, :reason, :created_at)`, &adj); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &domain.InventoryItem{
ProductID: productID,
SellerID: product.SellerID,
Name: product.Name,
Batch: product.Batch,
ExpiresAt: product.ExpiresAt,
Quantity: newStock,
PriceCents: product.PriceCents,
UpdatedAt: now,
}, nil
}
func (r *Repository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error) {
baseQuery := `FROM products`
args := []any{}
clauses := []string{}
if filter.ExpiringBefore != nil {
clauses = append(clauses, fmt.Sprintf("expires_at <= $%d", len(args)+1))
args = append(args, *filter.ExpiringBefore)
}
if filter.SellerID != nil {
clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1))
args = append(args, *filter.SellerID)
}
where := ""
if len(clauses) > 0 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
var total int64
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil {
return nil, 0, err
}
if filter.Limit <= 0 {
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf(`SELECT id AS product_id, seller_id, name, batch, expires_at, stock AS quantity, price_cents, updated_at %s%s ORDER BY expires_at ASC LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args))
var items []domain.InventoryItem
if err := r.db.SelectContext(ctx, &items, listQuery, args...); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error {
now := time.Now().UTC()
user.CreatedAt = now
user.UpdatedAt = now
query := `INSERT INTO users (id, company_id, role, name, email, password_hash, created_at, updated_at)
VALUES (:id, :company_id, :role, :name, :email, :password_hash, :created_at, :updated_at)`
_, err := r.db.NamedExecContext(ctx, query, user)
return err
}
func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) {
baseQuery := `FROM users`
var args []any
var clauses []string
if filter.CompanyID != nil {
clauses = append(clauses, fmt.Sprintf("company_id = $%d", len(args)+1))
args = append(args, *filter.CompanyID)
}
where := ""
if len(clauses) > 0 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
countQuery := "SELECT count(*) " + baseQuery + where
var total int64
if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil {
return nil, 0, err
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf("SELECT id, company_id, role, name, email, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
var users []domain.User
if err := r.db.SelectContext(ctx, &users, listQuery, args...); err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *Repository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) {
var user domain.User
query := `SELECT id, company_id, role, name, email, password_hash, created_at, updated_at FROM users WHERE id = $1`
if err := r.db.GetContext(ctx, &user, query, id); err != nil {
return nil, err
}
return &user, nil
}
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var user domain.User
query := `SELECT id, company_id, role, name, email, password_hash, created_at, updated_at FROM users WHERE email = $1`
if err := r.db.GetContext(ctx, &user, query, email); err != nil {
return nil, err
}
return &user, nil
}
func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
user.UpdatedAt = time.Now().UTC()
query := `UPDATE users
SET company_id = :company_id, role = :role, name = :name, email = :email, password_hash = :password_hash, updated_at = :updated_at
WHERE id = :id`
res, err := r.db.NamedExecContext(ctx, query, user)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("user not found")
}
return nil
}
func (r *Repository) DeleteUser(ctx context.Context, id uuid.UUID) error {
res, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("user not found")
}
return nil
}
func (r *Repository) AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) {
now := time.Now().UTC()
item.CreatedAt = now
item.UpdatedAt = now
query := `INSERT INTO cart_items (id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at)
VALUES (:id, :buyer_id, :product_id, :quantity, :unit_cents, :batch, :expires_at, :created_at, :updated_at)
ON CONFLICT (buyer_id, product_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity,
unit_cents = EXCLUDED.unit_cents,
batch = EXCLUDED.batch,
expires_at = EXCLUDED.expires_at,
updated_at = EXCLUDED.updated_at
RETURNING id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at`
var saved domain.CartItem
rows, err := r.db.NamedQueryContext(ctx, query, item)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
if err := rows.StructScan(&saved); err != nil {
return nil, err
}
return &saved, nil
}
return nil, errors.New("failed to persist cart item")
}
func (r *Repository) ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error) {
query := `SELECT ci.id, ci.buyer_id, ci.product_id, ci.quantity, ci.unit_cents, ci.batch, ci.expires_at, ci.created_at, ci.updated_at,
p.name AS product_name
FROM cart_items ci
JOIN products p ON p.id = ci.product_id
WHERE ci.buyer_id = $1
ORDER BY ci.created_at DESC`
var items []domain.CartItem
if err := r.db.SelectContext(ctx, &items, query, buyerID); err != nil {
return nil, err
}
return items, nil
}
func (r *Repository) DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error {
res, err := r.db.ExecContext(ctx, "DELETE FROM cart_items WHERE id = $1 AND buyer_id = $2", id, buyerID)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("cart item not found")
}
return nil
}
func (r *Repository) CreateReview(ctx context.Context, review *domain.Review) error {
now := time.Now().UTC()
review.CreatedAt = now
query := `INSERT INTO reviews (id, order_id, buyer_id, seller_id, rating, comment, created_at)
VALUES (:id, :order_id, :buyer_id, :seller_id, :rating, :comment, :created_at)`
_, err := r.db.NamedExecContext(ctx, query, review)
return err
}
func (r *Repository) GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error) {
var row struct {
Avg *float64 `db:"avg"`
Count int64 `db:"count"`
}
query := `SELECT AVG(rating)::float AS avg, COUNT(*) AS count FROM reviews WHERE seller_id = $1`
if err := r.db.GetContext(ctx, &row, query, companyID); err != nil {
return nil, err
}
avg := 0.0
if row.Avg != nil {
avg = *row.Avg
}
return &domain.CompanyRating{CompanyID: companyID, AverageScore: avg, TotalReviews: row.Count}, nil
}
func (r *Repository) SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) {
var sales struct {
Total *int64 `db:"total"`
Orders int64 `db:"orders"`
}
baseStatuses := []string{string(domain.OrderStatusPaid), string(domain.OrderStatusInvoiced), string(domain.OrderStatusDelivered)}
if err := r.db.GetContext(ctx, &sales, `SELECT COALESCE(SUM(total_cents), 0) AS total, COUNT(*) AS orders FROM orders WHERE seller_id = $1 AND status = ANY($2)`, sellerID, baseStatuses); err != nil {
return nil, err
}
var topProducts []domain.TopProduct
topQuery := `SELECT oi.product_id, p.name, SUM(oi.quantity) AS total_quantity, SUM(oi.quantity * oi.unit_cents) AS revenue_cents
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.seller_id = $1 AND o.status = ANY($2)
GROUP BY oi.product_id, p.name
ORDER BY total_quantity DESC
LIMIT 5`
if err := r.db.SelectContext(ctx, &topProducts, topQuery, sellerID, baseStatuses); err != nil {
return nil, err
}
var lowStock []domain.Product
if err := r.db.SelectContext(ctx, &lowStock, `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE seller_id = $1 AND stock <= 10 ORDER BY stock ASC, updated_at DESC`, sellerID); err != nil {
return nil, err
}
total := int64(0)
if sales.Total != nil {
total = *sales.Total
}
return &domain.SellerDashboard{
SellerID: sellerID,
TotalSalesCents: total,
OrdersCount: sales.Orders,
TopProducts: topProducts,
LowStockAlerts: lowStock,
}, nil
}
func (r *Repository) AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error) {
var gmv *int64
if err := r.db.GetContext(ctx, &gmv, `SELECT COALESCE(SUM(total_cents), 0) FROM orders WHERE status = ANY($1)`, []string{string(domain.OrderStatusPaid), string(domain.OrderStatusInvoiced), string(domain.OrderStatusDelivered)}); err != nil {
return nil, err
}
var newCompanies int64
if err := r.db.GetContext(ctx, &newCompanies, `SELECT COUNT(*) FROM companies WHERE created_at >= $1`, since); err != nil {
return nil, err
}
totalGMV := int64(0)
if gmv != nil {
totalGMV = *gmv
}
return &domain.AdminDashboard{GMVCents: totalGMV, NewCompanies: newCompanies, WindowStartAt: since}, nil
}
// InitSchema applies a minimal schema for development environments.
func (r *Repository) InitSchema(ctx context.Context) error {
schema := `
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY,
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),
role TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY,
seller_id UUID NOT NULL REFERENCES companies(id),
name TEXT NOT NULL,
description TEXT,
batch TEXT NOT NULL,
expires_at DATE NOT NULL,
price_cents BIGINT NOT NULL,
stock BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS inventory_adjustments (
id UUID PRIMARY KEY,
product_id UUID NOT NULL REFERENCES products(id),
delta BIGINT NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY,
buyer_id UUID NOT NULL REFERENCES companies(id),
seller_id UUID NOT NULL REFERENCES companies(id),
status TEXT NOT NULL,
total_cents BIGINT NOT NULL,
shipping_recipient_name TEXT,
shipping_street TEXT,
shipping_number TEXT,
shipping_complement TEXT,
shipping_district TEXT,
shipping_city TEXT,
shipping_state TEXT,
shipping_zip_code TEXT,
shipping_country TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS order_items (
id UUID PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
product_id UUID NOT NULL REFERENCES products(id),
quantity BIGINT NOT NULL,
unit_cents BIGINT NOT NULL,
batch TEXT NOT NULL,
expires_at DATE NOT NULL
);
CREATE TABLE IF NOT EXISTS cart_items (
id UUID PRIMARY KEY,
buyer_id UUID NOT NULL REFERENCES companies(id),
product_id UUID NOT NULL REFERENCES products(id),
quantity BIGINT NOT NULL,
unit_cents BIGINT NOT NULL,
batch TEXT,
expires_at DATE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (buyer_id, product_id)
);
CREATE TABLE IF NOT EXISTS reviews (
id UUID PRIMARY KEY,
order_id UUID NOT NULL UNIQUE REFERENCES orders(id),
buyer_id UUID NOT NULL REFERENCES companies(id),
seller_id UUID NOT NULL REFERENCES companies(id),
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_seller_id ON reviews (seller_id);
CREATE TABLE IF NOT EXISTS shipments (
id UUID PRIMARY KEY,
order_id UUID NOT NULL UNIQUE REFERENCES orders(id),
carrier TEXT NOT NULL,
tracking_code TEXT,
external_tracking TEXT,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
`
if _, err := r.db.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
return nil
}