Merge pull request #21 from rede5/codex/implement-advanced-search-repository-layer
Add marketplace advanced record search with windowed pagination and updated_at trigger
This commit is contained in:
commit
d63fb0da2d
9 changed files with 261 additions and 24 deletions
33
backend/internal/domain/search.go
Normal file
33
backend/internal/domain/search.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SearchRequest represents an API-level request for advanced listings.
|
||||||
|
type SearchRequest struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
SortBy string `json:"sort_by"`
|
||||||
|
SortOrder string `json:"sort_order"`
|
||||||
|
CreatedAfter *time.Time `json:"created_after,omitempty"`
|
||||||
|
CreatedBefore *time.Time `json:"created_before,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSearchFilter contains normalized filtering inputs for repositories.
|
||||||
|
type RecordSearchFilter struct {
|
||||||
|
Query string
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
|
CreatedAfter *time.Time
|
||||||
|
CreatedBefore *time.Time
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginationResponse represents a paginated response.
|
||||||
|
type PaginationResponse[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
CurrentPage int `json:"current_page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
@ -91,6 +91,10 @@ func (m *MockRepository) ListProducts(ctx context.Context, filter domain.Product
|
||||||
return m.products, int64(len(m.products)), nil
|
return m.products, int64(len(m.products)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error) {
|
||||||
|
return m.products, int64(len(m.products)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockRepository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
|
func (m *MockRepository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
|
||||||
for _, p := range m.products {
|
for _, p := range m.products {
|
||||||
if p.ID == id {
|
if p.ID == id {
|
||||||
|
|
|
||||||
58
backend/internal/http/handler/marketplace_handler.go
Normal file
58
backend/internal/http/handler/marketplace_handler.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/saveinmed/backend-go/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListMarketplaceRecords godoc
|
||||||
|
// @Summary Busca avançada no marketplace
|
||||||
|
// @Tags Marketplace
|
||||||
|
// @Produce json
|
||||||
|
// @Param query query string false "Busca textual"
|
||||||
|
// @Param sort_by query string false "Campo de ordenação (created_at|updated_at)"
|
||||||
|
// @Param sort_order query string false "Direção (asc|desc)"
|
||||||
|
// @Param created_after query string false "Data mínima (RFC3339)"
|
||||||
|
// @Param created_before query string false "Data máxima (RFC3339)"
|
||||||
|
// @Param page query integer false "Página"
|
||||||
|
// @Param page_size query integer false "Itens por página"
|
||||||
|
// @Success 200 {object} domain.PaginationResponse[domain.Product]
|
||||||
|
// @Router /api/v1/marketplace/records [get]
|
||||||
|
func (h *Handler) ListMarketplaceRecords(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page, pageSize := parsePagination(r)
|
||||||
|
|
||||||
|
req := domain.SearchRequest{
|
||||||
|
Query: r.URL.Query().Get("query"),
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
SortBy: r.URL.Query().Get("sort_by"),
|
||||||
|
SortOrder: r.URL.Query().Get("sort_order"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := r.URL.Query().Get("created_after"); v != "" {
|
||||||
|
createdAfter, err := time.Parse(time.RFC3339, v)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.CreatedAfter = &createdAfter
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("created_before"); v != "" {
|
||||||
|
createdBefore, err := time.Parse(time.RFC3339, v)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.CreatedBefore = &createdBefore
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.svc.ListRecords(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
ALTER TABLE products
|
||||||
|
ALTER COLUMN created_at SET DEFAULT NOW(),
|
||||||
|
ALTER COLUMN updated_at SET DEFAULT NOW();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS set_products_updated_at ON products;
|
||||||
|
CREATE TRIGGER set_products_updated_at
|
||||||
|
BEFORE UPDATE ON products
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
@ -158,15 +158,22 @@ func (r *Repository) DeleteCompany(ctx context.Context, id uuid.UUID) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) CreateProduct(ctx context.Context, product *domain.Product) error {
|
func (r *Repository) CreateProduct(ctx context.Context, product *domain.Product) error {
|
||||||
now := time.Now().UTC()
|
query := `INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock)
|
||||||
product.CreatedAt = now
|
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock)
|
||||||
product.UpdatedAt = now
|
RETURNING created_at, updated_at`
|
||||||
|
|
||||||
query := `INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
|
rows, err := r.db.NamedQueryContext(ctx, query, product)
|
||||||
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)`
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
_, err := r.db.NamedExecContext(ctx, query, product)
|
if rows.Next() {
|
||||||
return err
|
if err := rows.Scan(&product.CreatedAt, &product.UpdatedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error) {
|
func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error) {
|
||||||
|
|
@ -206,6 +213,73 @@ func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilt
|
||||||
return products, total, nil
|
return products, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRecords returns marketplace listings using a window function for total count.
|
||||||
|
func (r *Repository) ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error) {
|
||||||
|
baseQuery := `FROM products`
|
||||||
|
var args []any
|
||||||
|
var clauses []string
|
||||||
|
|
||||||
|
if filter.Query != "" {
|
||||||
|
clauses = append(clauses, fmt.Sprintf("(name ILIKE $%d OR description ILIKE $%d)", len(args)+1, len(args)+1))
|
||||||
|
args = append(args, "%"+filter.Query+"%")
|
||||||
|
}
|
||||||
|
if filter.CreatedAfter != nil {
|
||||||
|
clauses = append(clauses, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||||
|
args = append(args, *filter.CreatedAfter)
|
||||||
|
}
|
||||||
|
if filter.CreatedBefore != nil {
|
||||||
|
clauses = append(clauses, fmt.Sprintf("created_at <= $%d", len(args)+1))
|
||||||
|
args = append(args, *filter.CreatedBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
where := ""
|
||||||
|
if len(clauses) > 0 {
|
||||||
|
where = " WHERE " + strings.Join(clauses, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
sortColumns := map[string]string{
|
||||||
|
"created_at": "created_at",
|
||||||
|
"updated_at": "updated_at",
|
||||||
|
}
|
||||||
|
sortBy := sortColumns[strings.ToLower(filter.SortBy)]
|
||||||
|
if sortBy == "" {
|
||||||
|
sortBy = "updated_at"
|
||||||
|
}
|
||||||
|
sortOrder := strings.ToUpper(filter.SortOrder)
|
||||||
|
if sortOrder != "ASC" {
|
||||||
|
sortOrder = "DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
COUNT(*) OVER() AS total_count
|
||||||
|
%s%s ORDER BY %s %s LIMIT $%d OFFSET $%d`, baseQuery, where, sortBy, sortOrder, len(args)-1, len(args))
|
||||||
|
|
||||||
|
type recordRow struct {
|
||||||
|
domain.Product
|
||||||
|
TotalCount int64 `db:"total_count"`
|
||||||
|
}
|
||||||
|
var rows []recordRow
|
||||||
|
if err := r.db.SelectContext(ctx, &rows, listQuery, args...); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(0)
|
||||||
|
if len(rows) > 0 {
|
||||||
|
total = rows[0].TotalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]domain.Product, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, row.Product)
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SearchProducts returns products with distance from buyer, ordered by expiration date.
|
// SearchProducts returns products with distance from buyer, ordered by expiration date.
|
||||||
// Tenant info is anonymized (only city/state shown, not company name/ID).
|
// 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) {
|
func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error) {
|
||||||
|
|
@ -306,24 +380,27 @@ func (r *Repository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Prod
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) UpdateProduct(ctx context.Context, product *domain.Product) error {
|
func (r *Repository) UpdateProduct(ctx context.Context, product *domain.Product) error {
|
||||||
product.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
query := `UPDATE products
|
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
|
SET seller_id = :seller_id, name = :name, description = :description, batch = :batch, expires_at = :expires_at, price_cents = :price_cents, stock = :stock
|
||||||
WHERE id = :id`
|
WHERE id = :id
|
||||||
|
RETURNING updated_at`
|
||||||
|
|
||||||
res, err := r.db.NamedExecContext(ctx, query, product)
|
rows, err := r.db.NamedQueryContext(ctx, query, product)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rows, err := res.RowsAffected()
|
defer rows.Close()
|
||||||
if err != nil {
|
|
||||||
return err
|
if !rows.Next() {
|
||||||
}
|
if err := rows.Err(); err != nil {
|
||||||
if rows == 0 {
|
return err
|
||||||
|
}
|
||||||
return errors.New("product not found")
|
return errors.New("product not found")
|
||||||
}
|
}
|
||||||
return nil
|
if err := rows.Scan(&product.UpdatedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
func (r *Repository) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||||
|
|
|
||||||
|
|
@ -100,12 +100,12 @@ func TestCreateProduct(t *testing.T) {
|
||||||
Batch: "B1",
|
Batch: "B1",
|
||||||
PriceCents: 1000,
|
PriceCents: 1000,
|
||||||
Stock: 10,
|
Stock: 10,
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `INSERT INTO products`
|
query := `INSERT INTO products`
|
||||||
mock.ExpectExec(regexp.QuoteMeta(query)).
|
rows := sqlmock.NewRows([]string{"created_at", "updated_at"}).
|
||||||
|
AddRow(time.Now(), time.Now())
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(query)).
|
||||||
WithArgs(
|
WithArgs(
|
||||||
product.ID,
|
product.ID,
|
||||||
product.SellerID,
|
product.SellerID,
|
||||||
|
|
@ -115,10 +115,8 @@ func TestCreateProduct(t *testing.T) {
|
||||||
product.ExpiresAt,
|
product.ExpiresAt,
|
||||||
product.PriceCents,
|
product.PriceCents,
|
||||||
product.Stock,
|
product.Stock,
|
||||||
product.CreatedAt,
|
|
||||||
product.UpdatedAt,
|
|
||||||
).
|
).
|
||||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
WillReturnRows(rows)
|
||||||
|
|
||||||
err := repo.CreateProduct(context.Background(), product)
|
err := repo.CreateProduct(context.Background(), product)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ func New(cfg config.Config) (*Server, error) {
|
||||||
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/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("GET /api/v1/marketplace/records", chain(http.HandlerFunc(h.ListMarketplaceRecords), 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))
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,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)
|
SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error)
|
||||||
|
ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, 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
|
||||||
|
|
@ -248,6 +249,50 @@ func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearc
|
||||||
return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
|
return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRecords provides an advanced search for marketplace listings.
|
||||||
|
func (s *Service) ListRecords(ctx context.Context, req domain.SearchRequest) (*domain.PaginationResponse[domain.Product], error) {
|
||||||
|
page := req.Page
|
||||||
|
pageSize := req.PageSize
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBy := strings.TrimSpace(req.SortBy)
|
||||||
|
if sortBy == "" {
|
||||||
|
sortBy = "updated_at"
|
||||||
|
}
|
||||||
|
sortOrder := strings.ToLower(strings.TrimSpace(req.SortOrder))
|
||||||
|
if sortOrder == "" {
|
||||||
|
sortOrder = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := domain.RecordSearchFilter{
|
||||||
|
Query: strings.TrimSpace(req.Query),
|
||||||
|
SortBy: sortBy,
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
CreatedAfter: req.CreatedAfter,
|
||||||
|
CreatedBefore: req.CreatedBefore,
|
||||||
|
Limit: pageSize,
|
||||||
|
Offset: (page - 1) * pageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
items, total, err := s.repo.ListRecords(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||||
|
return &domain.PaginationResponse[domain.Product]{
|
||||||
|
Items: items,
|
||||||
|
TotalCount: total,
|
||||||
|
CurrentPage: page,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}, 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ func (m *MockRepository) ListProducts(ctx context.Context, filter domain.Product
|
||||||
return m.products, int64(len(m.products)), nil
|
return m.products, int64(len(m.products)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error) {
|
||||||
|
return m.products, int64(len(m.products)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockRepository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
|
func (m *MockRepository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
|
||||||
for _, p := range m.products {
|
for _, p := range m.products {
|
||||||
if p.ID == id {
|
if p.ID == id {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue