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
|
||||
}
|
||||
|
||||
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) {
|
||||
for _, p := range m.products {
|
||||
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,16 +158,23 @@ func (r *Repository) DeleteCompany(ctx context.Context, id uuid.UUID) error {
|
|||
}
|
||||
|
||||
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)
|
||||
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock)
|
||||
RETURNING created_at, updated_at`
|
||||
|
||||
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)
|
||||
rows, err := r.db.NamedQueryContext(ctx, query, product)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if rows.Next() {
|
||||
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) {
|
||||
baseQuery := `FROM products`
|
||||
|
|
@ -206,6 +213,73 @@ func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilt
|
|||
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.
|
||||
// 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) {
|
||||
|
|
@ -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 {
|
||||
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`
|
||||
SET seller_id = :seller_id, name = :name, description = :description, batch = :batch, expires_at = :expires_at, price_cents = :price_cents, stock = :stock
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -100,12 +100,12 @@ func TestCreateProduct(t *testing.T) {
|
|||
Batch: "B1",
|
||||
PriceCents: 1000,
|
||||
Stock: 10,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
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(
|
||||
product.ID,
|
||||
product.SellerID,
|
||||
|
|
@ -115,10 +115,8 @@ func TestCreateProduct(t *testing.T) {
|
|||
product.ExpiresAt,
|
||||
product.PriceCents,
|
||||
product.Stock,
|
||||
product.CreatedAt,
|
||||
product.UpdatedAt,
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
WillReturnRows(rows)
|
||||
|
||||
err := repo.CreateProduct(context.Background(), product)
|
||||
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/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/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("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
|
||||
ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, 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)
|
||||
UpdateProduct(ctx context.Context, product *domain.Product) 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
for _, p := range m.products {
|
||||
if p.ID == id {
|
||||
|
|
|
|||
Loading…
Reference in a new issue