From 4ad6a0aae57e98677779c1f2b640f3b23233fdf4 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sun, 21 Dec 2025 17:36:17 -0300 Subject: [PATCH] Add marketplace record search and audit trigger --- backend/internal/domain/search.go | 33 +++++ backend/internal/http/handler/handler_test.go | 4 + .../http/handler/marketplace_handler.go | 58 +++++++++ .../0003_products_updated_at_trigger.sql | 17 +++ .../internal/repository/postgres/postgres.go | 113 +++++++++++++++--- .../repository/postgres/repository_test.go | 10 +- backend/internal/server/server.go | 1 + backend/internal/usecase/usecase.go | 45 +++++++ backend/internal/usecase/usecase_test.go | 4 + 9 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 backend/internal/domain/search.go create mode 100644 backend/internal/http/handler/marketplace_handler.go create mode 100644 backend/internal/repository/postgres/migrations/0003_products_updated_at_trigger.sql diff --git a/backend/internal/domain/search.go b/backend/internal/domain/search.go new file mode 100644 index 0000000..2cbb6bd --- /dev/null +++ b/backend/internal/domain/search.go @@ -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"` +} diff --git a/backend/internal/http/handler/handler_test.go b/backend/internal/http/handler/handler_test.go index 8285372..d4a088e 100644 --- a/backend/internal/http/handler/handler_test.go +++ b/backend/internal/http/handler/handler_test.go @@ -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 { diff --git a/backend/internal/http/handler/marketplace_handler.go b/backend/internal/http/handler/marketplace_handler.go new file mode 100644 index 0000000..6a2c536 --- /dev/null +++ b/backend/internal/http/handler/marketplace_handler.go @@ -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) +} diff --git a/backend/internal/repository/postgres/migrations/0003_products_updated_at_trigger.sql b/backend/internal/repository/postgres/migrations/0003_products_updated_at_trigger.sql new file mode 100644 index 0000000..a4d801f --- /dev/null +++ b/backend/internal/repository/postgres/migrations/0003_products_updated_at_trigger.sql @@ -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(); diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index c794d3b..c9046ef 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -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 { - 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)` + rows, err := r.db.NamedQueryContext(ctx, query, product) + if err != nil { + return err + } + defer rows.Close() - _, err := r.db.NamedExecContext(ctx, query, product) - return err + 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) { @@ -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 { - return err - } - if rows == 0 { + defer rows.Close() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } 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 { diff --git a/backend/internal/repository/postgres/repository_test.go b/backend/internal/repository/postgres/repository_test.go index 9592e56..3b1c865 100644 --- a/backend/internal/repository/postgres/repository_test.go +++ b/backend/internal/repository/postgres/repository_test.go @@ -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) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index b59997b..5a07a81 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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)) diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index faf2e71..153595d 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -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) } diff --git a/backend/internal/usecase/usecase_test.go b/backend/internal/usecase/usecase_test.go index d45c44c..25f0231 100644 --- a/backend/internal/usecase/usecase_test.go +++ b/backend/internal/usecase/usecase_test.go @@ -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 {