From 6dac5b482a1065f5bf134cf6cc8e69918d6b5735 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 17 May 2026 17:21:36 +0330 Subject: [PATCH] hybrid pagination --- internal/cms/handler.go | 8 +- internal/cms/repository.go | 21 ++++- internal/cms/service.go | 16 +--- internal/company/handler.go | 10 ++- internal/company/repository.go | 44 +++++----- internal/company/service.go | 9 +- internal/company_category/handler.go | 10 ++- internal/company_category/repository.go | 38 ++++----- internal/company_category/service.go | 9 +- internal/contact/handler.go | 8 +- internal/contact/repository.go | 23 ++++-- internal/contact/service.go | 16 +--- internal/customer/handler.go | 8 +- internal/customer/repository.go | 29 ++++--- internal/customer/service.go | 12 +-- internal/feedback/form.go | 2 +- internal/feedback/handler.go | 25 ++++-- internal/feedback/repository.go | 44 +++++----- internal/feedback/service.go | 14 +--- internal/inquiry/handler.go | 10 ++- internal/inquiry/repository.go | 38 ++++----- internal/inquiry/service.go | 12 ++- internal/notification/handler.go | 28 +++++-- internal/notification/repository.go | 50 ++++------- internal/notification/service.go | 18 +--- internal/tender/handler.go | 37 ++++++--- internal/tender/repository.go | 29 ++++--- internal/tender/service.go | 8 +- internal/user/handler.go | 10 ++- internal/user/repository.go | 40 ++++----- internal/user/service.go | 9 +- pkg/mongo/cursor.go | 38 +++++---- pkg/mongo/cursor_test.go | 55 +++++++++++++ pkg/mongo/filter_hash.go | 21 +++++ pkg/mongo/list_pagination.go | 78 ++++++++++++++++++ pkg/mongo/repository.go | 28 +++++-- pkg/response/pagination.go | 105 ++++++++++++++++++++++++ pkg/response/pagination_test.go | 47 +++++++++++ pkg/response/response.go | 75 ++++------------- 39 files changed, 696 insertions(+), 386 deletions(-) create mode 100644 pkg/mongo/cursor_test.go create mode 100644 pkg/mongo/filter_hash.go create mode 100644 pkg/mongo/list_pagination.go create mode 100644 pkg/response/pagination.go create mode 100644 pkg/response/pagination_test.go diff --git a/internal/cms/handler.go b/internal/cms/handler.go index 6dfcb2a..37b9370 100644 --- a/internal/cms/handler.go +++ b/internal/cms/handler.go @@ -179,10 +179,16 @@ func (h *Handler) Search(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - pagination := response.NewPagination(c) + pagination, err := response.NewPagination(c) + if err != nil { + return response.PaginationBadRequest(c, err) + } result, err := h.service.Search(c.Request().Context(), form, pagination) if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to search CMS entries") } diff --git a/internal/cms/repository.go b/internal/cms/repository.go index 453a746..dbe5b1e 100644 --- a/internal/cms/repository.go +++ b/internal/cms/repository.go @@ -6,6 +6,7 @@ import ( "time" "tm/pkg/logger" orm "tm/pkg/mongo" + "tm/pkg/response" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -17,7 +18,7 @@ type Repository interface { Delete(ctx context.Context, id string) error GetByID(ctx context.Context, id string) (*CMS, error) GetByKey(ctx context.Context, key string) (*CMS, error) - Search(ctx context.Context, search *SearchCMSForm, pagination orm.Pagination) (*orm.PaginatedResult[CMS], error) + Search(ctx context.Context, search *SearchCMSForm, pagination *response.Pagination) (*orm.PaginatedResult[CMS], error) } // cmsRepository implements the Repository interface using the MongoDB ORM @@ -169,7 +170,7 @@ func (r *cmsRepository) Delete(ctx context.Context, id string) error { } // Search searches CMS entries with filters and pagination -func (r *cmsRepository) Search(ctx context.Context, search *SearchCMSForm, pagination orm.Pagination) (*orm.PaginatedResult[CMS], error) { +func (r *cmsRepository) Search(ctx context.Context, search *SearchCMSForm, pagination *response.Pagination) (*orm.PaginatedResult[CMS], error) { filter := bson.M{} if search.Search != nil && *search.Search != "" { @@ -182,8 +183,20 @@ func (r *cmsRepository) Search(ctx context.Context, search *SearchCMSForm, pagin filter["key"] = bson.M{"$regex": search.Key, "$options": "i"} } - // Use ORM to find CMS entries - result, err := r.ormRepo.FindAll(ctx, filter, pagination) + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) + if err != nil { + return nil, err + } + + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to search CMS entries", map[string]interface{}{ "error": err.Error(), diff --git a/internal/cms/service.go b/internal/cms/service.go index a8cd34c..6302922 100644 --- a/internal/cms/service.go +++ b/internal/cms/service.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "tm/pkg/logger" - orm "tm/pkg/mongo" "tm/pkg/response" ) @@ -197,18 +196,7 @@ func (s *cmsService) Search(ctx context.Context, form *SearchCMSForm, pagination "pagination": pagination, }) - // Convert response.Pagination to orm.Pagination - ormPagination := orm.Pagination{ - Limit: pagination.Limit, - Skip: pagination.Offset, - SortField: pagination.SortBy, - SortOrder: 1, // Default ascending - } - if pagination.SortOrder == "desc" { - ormPagination.SortOrder = -1 - } - - result, err := s.repo.Search(ctx, form, ormPagination) + result, err := s.repo.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to search CMS entries", map[string]interface{}{ "filters": form, @@ -230,7 +218,7 @@ func (s *cmsService) Search(ctx context.Context, form *SearchCMSForm, pagination return &CMSListResponse{ CMS: cmsResponses, - Meta: pagination.Response(result.TotalCount), + Meta: pagination.ListMeta(result.TotalCount, result.NextCursor, result.HasMore, result.PageOffset), }, nil } diff --git a/internal/company/handler.go b/internal/company/handler.go index e594b36..6bd3cf2 100644 --- a/internal/company/handler.go +++ b/internal/company/handler.go @@ -222,8 +222,16 @@ func (h *Handler) Search(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - result, err := h.service.SearchCompanies(c.Request().Context(), form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { + return response.PaginationBadRequest(c, err) + } + + result, err := h.service.SearchCompanies(c.Request().Context(), form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to list companies") } diff --git a/internal/company/repository.go b/internal/company/repository.go index 85be786..a7c04c2 100644 --- a/internal/company/repository.go +++ b/internal/company/repository.go @@ -20,7 +20,7 @@ type Repository interface { GetByName(ctx context.Context, name string) (*Company, error) GetByRegistrationNumber(ctx context.Context, registrationNumber string) (*Company, error) GetByTaxID(ctx context.Context, taxID string) (*Company, error) - Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Company, int64, error) + Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Company], error) Delete(ctx context.Context, id string) error UpdateStatus(ctx context.Context, id string, status CompanyStatus) error UpdateVerification(ctx context.Context, id string, isVerified, isCompliant bool, complianceNotes *string) error @@ -194,7 +194,7 @@ func (r *companyRepository) Delete(ctx context.Context, id string) error { } // Search retrieves companies with search and filters -func (r *companyRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Company, int64, error) { +func (r *companyRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Company], error) { // Build filter filter := bson.M{} @@ -281,34 +281,28 @@ func (r *companyRepository) Search(ctx context.Context, form *SearchForm, pagina filter["founded_year"] = rangeFilter } - // Build sort - sort := "created_at" - if pagination.SortBy != "" { - sort = pagination.SortBy - } - - sortOrder := "desc" - if pagination.SortOrder != "" { - sortOrder = pagination.SortOrder - } - - // Build pagination - paginationBuilder := orm.NewPaginationBuilder(). - Limit(pagination.Limit). - Skip(pagination.Offset). - SortBy(sort, sortOrder). - Build() - - // Use ORM to find users - result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder) + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) if err != nil { - r.logger.Error("Failed to list users", map[string]interface{}{ + return nil, err + } + + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) + if err != nil { + r.logger.Error("Failed to search companies", map[string]interface{}{ "error": err.Error(), }) - return nil, 0, err + return nil, err } - return result.Items, result.TotalCount, nil + return result, nil } // UpdateStatus updates company status diff --git a/internal/company/service.go b/internal/company/service.go index 4d6056d..fb85f1d 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -321,17 +321,16 @@ func (s *companyService) Delete(ctx context.Context, id string) error { func (s *companyService) SearchCompanies(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CompanyListResponse, error) { // Get companies using search - companies, total, err := s.repository.Search(ctx, form, pagination) + page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to search companies", map[string]interface{}{ "error": err.Error(), }) - return nil, errors.New("failed to search companies") + return nil, err } - // Convert to responses var companyResponses []*CompanyResponse - for _, company := range companies { + for _, company := range page.Items { // Get category details for response categoryDetails, err := s.categoryService.GetByIDs(ctx, company.Tags.Categories) if err != nil { @@ -354,7 +353,7 @@ func (s *companyService) SearchCompanies(ctx context.Context, form *SearchForm, return &CompanyListResponse{ Companies: companyResponses, - Meta: pagination.Response(total), + Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } diff --git a/internal/company_category/handler.go b/internal/company_category/handler.go index 31f5486..1e7a578 100644 --- a/internal/company_category/handler.go +++ b/internal/company_category/handler.go @@ -169,8 +169,16 @@ func (h *Handler) Search(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - result, err := h.service.SearchCategories(c.Request().Context(), form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { + return response.PaginationBadRequest(c, err) + } + + result, err := h.service.SearchCategories(c.Request().Context(), form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to search categories") } diff --git a/internal/company_category/repository.go b/internal/company_category/repository.go index 46084e7..aee31aa 100644 --- a/internal/company_category/repository.go +++ b/internal/company_category/repository.go @@ -18,7 +18,7 @@ type Repository interface { GetByID(ctx context.Context, id string) (*Category, error) GetByIDs(ctx context.Context, ids []string) ([]Category, error) GetByName(ctx context.Context, name string) (*Category, error) - Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Category, int64, error) + Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Category], error) Delete(ctx context.Context, id string) error TogglePublish(ctx context.Context, id string) (bool, error) } @@ -178,7 +178,7 @@ func (r *categoryRepository) Delete(ctx context.Context, id string) error { } // Search retrieves categories with search and filters -func (r *categoryRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Category, int64, error) { +func (r *categoryRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Category], error) { // Build filter filter := bson.M{} @@ -198,34 +198,28 @@ func (r *categoryRepository) Search(ctx context.Context, form *SearchForm, pagin filter["published"] = *form.Published } - // Build sort - sort := "created_at" - if pagination.SortBy != "" { - sort = pagination.SortBy + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) + if err != nil { + return nil, err } - sortOrder := "desc" - if pagination.SortOrder != "" { - sortOrder = pagination.SortOrder - } - - // Build pagination - paginationBuilder := orm.NewPaginationBuilder(). - Limit(pagination.Limit). - Skip(pagination.Offset). - SortBy(sort, sortOrder). - Build() - - // Use ORM to find categories - result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder) + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to search categories", map[string]interface{}{ "error": err.Error(), }) - return nil, 0, err + return nil, err } - return result.Items, result.TotalCount, nil + return result, nil } // TogglePublish toggles category publish status diff --git a/internal/company_category/service.go b/internal/company_category/service.go index 1d48033..a05e872 100644 --- a/internal/company_category/service.go +++ b/internal/company_category/service.go @@ -177,23 +177,22 @@ func (s *categoryService) Delete(ctx context.Context, id string) error { // SearchCategories searches categories with filters func (s *categoryService) SearchCategories(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CategoryListResponse, error) { // Get categories using search - categories, total, err := s.repository.Search(ctx, form, pagination) + page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to search categories", map[string]interface{}{ "error": err.Error(), }) - return nil, errors.New("failed to search categories") + return nil, err } - // Convert to responses var categoryResponses []*CategoryResponse - for _, category := range categories { + for _, category := range page.Items { categoryResponses = append(categoryResponses, category.ToResponse()) } return &CategoryListResponse{ Categories: categoryResponses, - Meta: pagination.Response(total), + Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } diff --git a/internal/contact/handler.go b/internal/contact/handler.go index a998891..7f446fb 100644 --- a/internal/contact/handler.go +++ b/internal/contact/handler.go @@ -113,10 +113,16 @@ func (h *Handler) Search(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - pagination := response.NewPagination(c) + pagination, err := response.NewPagination(c) + if err != nil { + return response.PaginationBadRequest(c, err) + } result, err := h.service.Search(c.Request().Context(), form, pagination) if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to search contacts") } diff --git a/internal/contact/repository.go b/internal/contact/repository.go index bfe6a6b..021acbf 100644 --- a/internal/contact/repository.go +++ b/internal/contact/repository.go @@ -6,6 +6,7 @@ import ( "time" "tm/pkg/logger" orm "tm/pkg/mongo" + "tm/pkg/response" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" @@ -20,7 +21,7 @@ type ContactRepository interface { GetByID(ctx context.Context, id string) (*Contact, error) // Search searches contacts with filters and pagination - Search(ctx context.Context, filters SearchContactsForm, pagination orm.Pagination) (*orm.PaginatedResult[Contact], error) + Search(ctx context.Context, filters SearchContactsForm, pagination *response.Pagination) (*orm.PaginatedResult[Contact], error) // UpdateStatus updates the status of a contact UpdateStatus(ctx context.Context, id string, status ContactStatus, adminNotes *string, resolvedBy *string) error @@ -87,20 +88,17 @@ func (r *contactRepository) GetByID(ctx context.Context, id string) (*Contact, e } // Search searches contacts with filters and pagination -func (r *contactRepository) Search(ctx context.Context, filters SearchContactsForm, pagination orm.Pagination) (*orm.PaginatedResult[Contact], error) { +func (r *contactRepository) Search(ctx context.Context, filters SearchContactsForm, pagination *response.Pagination) (*orm.PaginatedResult[Contact], error) { filter := bson.M{} - // Apply text search if provided if filters.Search != nil && *filters.Search != "" { filter["$text"] = bson.M{"$search": *filters.Search} } - // Apply status filter if filters.Status != nil && *filters.Status != "" { filter["status"] = *filters.Status } - // Apply date range filters if filters.DateFrom != nil || filters.DateTo != nil { dateFilter := bson.M{} if filters.DateFrom != nil { @@ -112,7 +110,20 @@ func (r *contactRepository) Search(ctx context.Context, filters SearchContactsFo filter["created_at"] = dateFilter } - return r.repo.FindAll(ctx, filter, pagination) + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) + if err != nil { + return nil, err + } + + return r.repo.FindAll(ctx, filter, mongoPagination) } // UpdateStatus updates the status of a contact diff --git a/internal/contact/service.go b/internal/contact/service.go index 57361aa..d421429 100644 --- a/internal/contact/service.go +++ b/internal/contact/service.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "tm/pkg/logger" - orm "tm/pkg/mongo" "tm/pkg/notification" "tm/pkg/response" ) @@ -155,18 +154,7 @@ func (s *contactService) Search(ctx context.Context, form *SearchContactsForm, p return nil, fmt.Errorf("invalid date range: date_from must be before date_to") } - // Convert response.Pagination to orm.Pagination - ormPagination := orm.Pagination{ - Limit: pagination.Limit, - Skip: pagination.Offset, - SortField: pagination.SortBy, - SortOrder: 1, // Default ascending - } - if pagination.SortOrder == "desc" { - ormPagination.SortOrder = -1 - } - - result, err := s.repo.Search(ctx, *form, ormPagination) + result, err := s.repo.Search(ctx, *form, pagination) if err != nil { s.logger.Error("Failed to search contacts", map[string]interface{}{ "filters": form, @@ -188,7 +176,7 @@ func (s *contactService) Search(ctx context.Context, form *SearchContactsForm, p return &ContactListResponse{ Contacts: contactResponses, - Meta: pagination.Response(result.TotalCount), + Meta: pagination.ListMeta(result.TotalCount, result.NextCursor, result.HasMore, result.PageOffset), }, nil } diff --git a/internal/customer/handler.go b/internal/customer/handler.go index 132b238..266ea25 100644 --- a/internal/customer/handler.go +++ b/internal/customer/handler.go @@ -191,10 +191,16 @@ func (h *Handler) Search(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - pagination := response.NewPagination(c) + pagination, err := response.NewPagination(c) + if err != nil { + return response.PaginationBadRequest(c, err) + } result, err := h.service.Search(c.Request().Context(), form, pagination) if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to list customers") } diff --git a/internal/customer/repository.go b/internal/customer/repository.go index 1dd3dd8..ba9bb24 100644 --- a/internal/customer/repository.go +++ b/internal/customer/repository.go @@ -24,7 +24,7 @@ type Repository interface { GetByRole(ctx context.Context, role CustomerRole) ([]Customer, error) GetByCompanies(ctx context.Context, companies []string) ([]Customer, error) GetByCompanyID(ctx context.Context, companyID string, pagination *response.Pagination) ([]*Customer, int64, error) - Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]Customer, int64, error) + Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*orm.PaginatedResult[Customer], error) Delete(ctx context.Context, id string) error } @@ -329,8 +329,7 @@ func (r *customerRepository) Delete(ctx context.Context, id string) error { } // Search retrieves customers with search and filters -func (r *customerRepository) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]Customer, int64, error) { - // Build filter +func (r *customerRepository) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*orm.PaginatedResult[Customer], error) { filter := bson.M{} if form.Search != nil { @@ -349,23 +348,29 @@ func (r *customerRepository) Search(ctx context.Context, form *SearchCustomersFo filter["company_id"] = *form.CompanyID } - // Build pagination - paginationBuilder := orm.NewPaginationBuilder(). - Limit(pagination.Limit). - Skip(pagination.Offset). - SortBy(pagination.SortBy, pagination.SortOrder) + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) + if err != nil { + return nil, err + } - // Use ORM to find customers - result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder.Build()) + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to search customers", map[string]interface{}{ "error": err.Error(), "search": form.Search, }) - return nil, 0, err + return nil, err } - return result.Items, result.TotalCount, nil + return result, nil } // UpdateStatus updates customer status diff --git a/internal/customer/service.go b/internal/customer/service.go index ea54daa..e39f891 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -218,16 +218,16 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp // Search searches for customers func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*CustomerListResponse, error) { - customers, total, err := s.repository.Search(ctx, form, pagination) + page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to search customers", map[string]interface{}{ "error": err.Error(), }) - return nil, errors.New("failed to search customers") + return nil, err } var customerResponses []*CustomerResponse - for _, customer := range customers { + for _, customer := range page.Items { companies, err := s.loadCompaniesForCustomer(ctx, &customer) if err != nil { s.logger.Error("Failed to load companies for customer", map[string]interface{}{ @@ -241,12 +241,12 @@ func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm, return &CustomerListResponse{ Customers: customerResponses, - Meta: pagination.Response(total), + Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) { - customers, _, err := s.repository.Search(ctx, form, pagination) + page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to search customers", map[string]interface{}{ "error": err.Error(), @@ -255,7 +255,7 @@ func (s *customerService) SearchNotification(ctx context.Context, form *SearchCu } var customerResponses []*CustomerNotificationResponse - for _, customer := range customers { + for _, customer := range page.Items { customerResponses = append(customerResponses, customer.ToNotificationResponse()) } diff --git a/internal/feedback/form.go b/internal/feedback/form.go index ddf67b7..06a39ee 100644 --- a/internal/feedback/form.go +++ b/internal/feedback/form.go @@ -62,7 +62,7 @@ type ( type FeedbackListResponse struct { Feedback []*FeedbackResponse `json:"feedback"` - Meta *response.Meta `json:"meta"` + Meta *response.Meta `json:"-"` } func (f *Feedback) ToResponse(tender *tenderResponse, company *companyResponse) *FeedbackResponse { diff --git a/internal/feedback/handler.go b/internal/feedback/handler.go index 3f8c947..f1e8d32 100644 --- a/internal/feedback/handler.go +++ b/internal/feedback/handler.go @@ -60,12 +60,20 @@ func (h *Handler) Search(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - feedbackList, err := h.service.Search(c.Request().Context(), *form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { + return response.PaginationBadRequest(c, err) + } + + feedbackList, err := h.service.Search(c.Request().Context(), *form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to retrieve feedback list") } - return response.Success(c, feedbackList.Feedback, "feedback list retrieved successfully") + return response.SuccessWithMeta(c, feedbackList, feedbackList.Meta, "feedback list retrieved successfully") } // GetFeedbackByID retrieves feedback by ID with loaded related data @@ -217,13 +225,20 @@ func (h *Handler) PublicList(c echo.Context) error { } form.Company = &companyID - // Call service - result, err := h.service.Search(c.Request().Context(), *form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { + return response.PaginationBadRequest(c, err) + } + + result, err := h.service.Search(c.Request().Context(), *form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to list feedback") } - return response.Success(c, result, "Feedback list retrieved successfully") + return response.SuccessWithMeta(c, result, result.Meta, "Feedback list retrieved successfully") } // PublicGetFeedback retrieves feedback by ID diff --git a/internal/feedback/repository.go b/internal/feedback/repository.go index ef0388b..321d138 100644 --- a/internal/feedback/repository.go +++ b/internal/feedback/repository.go @@ -16,7 +16,7 @@ type Repository interface { // Basic CRUD operations Create(ctx context.Context, feedback *Feedback) error Update(ctx context.Context, feedback *Feedback) error - Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) ([]Feedback, int64, error) + Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Feedback], error) GetByID(ctx context.Context, id string) (*Feedback, error) GetByCompanyID(ctx context.Context, companyID string, tender string) (*Feedback, error) GetByTenderID(ctx context.Context, tenderID, companyID string) (*Feedback, error) @@ -172,37 +172,31 @@ func (r *feedbackRepository) Delete(ctx context.Context, id string) error { } // Search searches feedback based on criteria -func (r *feedbackRepository) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) ([]Feedback, int64, error) { +func (r *feedbackRepository) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Feedback], error) { filter := r.buildSearchFilter(criteria) - // Build sort - sort := "created_at" - if pagination.SortBy != "" { - sort = pagination.SortBy - } - - sortOrder := "desc" - if pagination.SortOrder != "" { - sortOrder = pagination.SortOrder - } - - // Build pagination - paginationBuilder := orm.NewPaginationBuilder(). - Limit(pagination.Limit). - Skip(pagination.Offset). - SortBy(sort, sortOrder). - Build() - - // Use ORM to find users - result, err := r.repo.FindAll(ctx, filter, paginationBuilder) + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) if err != nil { - r.logger.Error("Failed to list users", map[string]interface{}{ + return nil, err + } + + result, err := r.repo.FindAll(ctx, filter, mongoPagination) + if err != nil { + r.logger.Error("Failed to search feedback", map[string]interface{}{ "error": err.Error(), }) - return nil, 0, err + return nil, err } - return result.Items, result.TotalCount, nil + return result, nil } // Stats calculates feedback statistics for a company diff --git a/internal/feedback/service.go b/internal/feedback/service.go index 00f1cf3..4c260e0 100644 --- a/internal/feedback/service.go +++ b/internal/feedback/service.go @@ -152,7 +152,7 @@ func (s *feedbackService) Get(ctx context.Context, id string) (*FeedbackResponse } func (s *feedbackService) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*FeedbackListResponse, error) { - feedbacks, total, err := s.feedbackRepo.Search(ctx, criteria, pagination) + page, err := s.feedbackRepo.Search(ctx, criteria, pagination) if err != nil { s.logger.Error("Failed to list feedback", map[string]interface{}{ "criteria": criteria, @@ -163,16 +163,10 @@ func (s *feedbackService) Search(ctx context.Context, criteria SearchForm, pagin return nil, err } - meta := &response.Meta{ - Total: int(total), - Limit: pagination.Limit, - Offset: pagination.Offset, - Page: (pagination.Offset / pagination.Limit) + 1, - Pages: int((total + int64(pagination.Limit) - 1) / int64(pagination.Limit)), - } + meta := pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset) - result := make([]*FeedbackResponse, len(feedbacks)) - for i, feedback := range feedbacks { + result := make([]*FeedbackResponse, len(page.Items)) + for i, feedback := range page.Items { tender, company, _ := s.getDetails(ctx, feedback.TenderID, *feedback.CompanyID) result[i] = feedback.ToResponse(tender, company) } diff --git a/internal/inquiry/handler.go b/internal/inquiry/handler.go index 718259d..278a241 100644 --- a/internal/inquiry/handler.go +++ b/internal/inquiry/handler.go @@ -185,8 +185,16 @@ func (h *Handler) SearchInquiries(c echo.Context) error { } // Call service - inquiries, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { + return response.PaginationBadRequest(c, err) + } + + inquiries, err := h.service.Search(c.Request().Context(), form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to search inquiries") } diff --git a/internal/inquiry/repository.go b/internal/inquiry/repository.go index b7b0194..9bda555 100644 --- a/internal/inquiry/repository.go +++ b/internal/inquiry/repository.go @@ -16,7 +16,7 @@ type Repository interface { Create(ctx context.Context, inquiry *Inquiry) error GetByID(ctx context.Context, id string) (*Inquiry, error) UpdateStatus(ctx context.Context, id string, status, reason, changedBy, description string) error - Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) ([]Inquiry, int64, error) + Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) (*orm.PaginatedResult[Inquiry], error) Delete(ctx context.Context, id string) error CheckPendingInquiry(ctx context.Context, email string) (*Inquiry, error) CheckPendingInquiryByPhone(ctx context.Context, phone string) (*Inquiry, error) @@ -206,7 +206,7 @@ func (r *inquiryRepository) CheckPendingInquiryByPhone(ctx context.Context, phon } // Search retrieves inquiries with pagination and filters -func (r *inquiryRepository) Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) ([]Inquiry, int64, error) { +func (r *inquiryRepository) Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) (*orm.PaginatedResult[Inquiry], error) { filter := bson.M{} // Add search filter @@ -223,34 +223,28 @@ func (r *inquiryRepository) Search(ctx context.Context, form *SearchInquiriesFor filter["status"] = *form.Status } - // Build sort - sort := "created_at" - if pagination.SortBy != "" { - sort = pagination.SortBy + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) + if err != nil { + return nil, err } - sortOrder := "desc" - if pagination.SortOrder != "" { - sortOrder = pagination.SortOrder - } - - // Build pagination - paginationBuilder := orm.NewPaginationBuilder(). - Limit(pagination.Limit). - Skip(pagination.Offset). - SortBy(sort, sortOrder). - Build() - - // Use ORM to find inquiries - result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder) + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to search inquiries", map[string]interface{}{ "error": err.Error(), }) - return nil, 0, err + return nil, err } - return result.Items, result.TotalCount, nil + return result, nil } // Delete deletes an inquiry diff --git a/internal/inquiry/service.go b/internal/inquiry/service.go index b32ea9d..937b419 100644 --- a/internal/inquiry/service.go +++ b/internal/inquiry/service.go @@ -273,29 +273,27 @@ func (s *inquiryService) Search(ctx context.Context, form *SearchInquiriesForm, "offset": pagination.Offset, }) - // Get inquiries - inquiries, total, err := s.repository.Search(ctx, form, pagination) + page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to search inquiries", map[string]interface{}{ "error": err.Error(), }) - return nil, errors.New("failed to search inquiries") + return nil, err } - // Convert to responses var inquiryResponses []*InquiryResponse - for _, inquiry := range inquiries { + for _, inquiry := range page.Items { inquiryResponses = append(inquiryResponses, inquiry.ToResponse()) } s.logger.Info("Inquiries search completed successfully", map[string]interface{}{ - "total_found": total, + "total_found": page.TotalCount, "returned": len(inquiryResponses), }) return &InquiryListResponse{ Inquiries: inquiryResponses, - Meta: pagination.Response(total), + Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } diff --git a/internal/notification/handler.go b/internal/notification/handler.go index 706e2f8..7983d24 100644 --- a/internal/notification/handler.go +++ b/internal/notification/handler.go @@ -87,11 +87,16 @@ func (h *NotificationHandler) GetNotifications(c echo.Context) error { return response.ValidationError(c, err.Error(), "") } - pagination := response.NewPagination(c) + pagination, err := response.NewPagination(c) + if err != nil { + return response.PaginationBadRequest(c, err) + } - // Call service notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination) if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to get notifications") } @@ -141,11 +146,16 @@ func (h *NotificationHandler) AdminNotifications(c echo.Context) error { form.Recipient = userID form.Status = "sent" - pagination := response.NewPagination(c) + pagination, err := response.NewPagination(c) + if err != nil { + return response.PaginationBadRequest(c, err) + } - // Call service notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination) if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to get notifications") } @@ -254,12 +264,16 @@ func (h *NotificationHandler) CustomerNotifications(c echo.Context) error { form.Recipient = userID form.Status = "sent" - pagination := response.NewPagination(c) + pagination, err := response.NewPagination(c) + if err != nil { + return response.PaginationBadRequest(c, err) + } - // Call service notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination) if err != nil { - // Log the actual error for debugging + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } c.Logger().Error("GetNotifications failed", map[string]interface{}{ "error": err.Error(), "status": form.Status, diff --git a/internal/notification/repository.go b/internal/notification/repository.go index 79d6c0a..2f6179c 100644 --- a/internal/notification/repository.go +++ b/internal/notification/repository.go @@ -11,7 +11,6 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" mongopkg "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" ) var ( @@ -45,7 +44,7 @@ type Notification struct { type Repository interface { Create(ctx context.Context, notification *Notification) error GetByID(ctx context.Context, id string) (*Notification, error) - GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) ([]Notification, int64, error) + GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) Update(ctx context.Context, notification *Notification) error MarkAsSeen(ctx context.Context, notificationID, userID string) error MarkAllAsSeen(ctx context.Context, userID string) error @@ -108,7 +107,7 @@ func (r *notificationRepository) GetByID(ctx context.Context, id string) (*Notif } // GetByUserID retrieves notifications for a specific user with filters -func (r *notificationRepository) GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) ([]Notification, int64, error) { +func (r *notificationRepository) GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) { filter := bson.M{"user_id": userID} if status != "" { @@ -131,48 +130,29 @@ func (r *notificationRepository) GetByUserID(ctx context.Context, userID string, filter["type"] = notificationType } - // Get total count - total, err := r.collection.CountDocuments(ctx, filter) + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) if err != nil { - r.logger.Error("Failed to count notifications", map[string]interface{}{ - "error": err.Error(), - "user_id": userID, - }) - return nil, 0, err + return nil, err } - // Build find options - findOptions := options.Find() - if pagination.Limit > 0 { - findOptions.SetLimit(int64(pagination.Limit)) - } - if pagination.Offset > 0 { - findOptions.SetSkip(int64(pagination.Offset)) - } - - // Sort by created_at descending - findOptions.SetSort(bson.D{{Key: "created_at", Value: -1}}) - - cursor, err := r.collection.Find(ctx, filter, findOptions) + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to find notifications", map[string]interface{}{ "error": err.Error(), "user_id": userID, }) - return nil, 0, err - } - defer cursor.Close(ctx) - - var notifications []Notification - if err := cursor.All(ctx, ¬ifications); err != nil { - r.logger.Error("Failed to decode notifications", map[string]interface{}{ - "error": err.Error(), - "user_id": userID, - }) - return nil, 0, err + return nil, err } - return notifications, total, nil + return result, nil } // Update updates a notification diff --git a/internal/notification/service.go b/internal/notification/service.go index 8de3dcc..335be65 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -194,7 +194,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF "offset": pagination.Offset, }) - notifications, total, err := s.repository.GetByUserID(ctx, req.Recipient, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination) + page, err := s.repository.GetByUserID(ctx, req.Recipient, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination) if err != nil { s.logger.Error("Failed to get notifications", map[string]interface{}{ "error": err.Error(), @@ -203,7 +203,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF } notificationsResponse := make([]*NotificationResponse, 0) - for _, notification := range notifications { + for _, notification := range page.Items { usr, _ := s.userService.GetUserByID(ctx, notification.UserID) cust, _ := s.customerService.GetByID(ctx, notification.UserID) @@ -250,21 +250,9 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF }) } - // Calculate total pages - totalPages := int(total) / pagination.Limit - if int(total)%pagination.Limit > 0 { - totalPages++ - } - return &NotificationListResponse{ Notifications: notificationsResponse, - Meta: &response.Meta{ - Total: int(total), - Limit: pagination.Limit, - Offset: pagination.Offset, - Page: (pagination.Offset / pagination.Limit) + 1, - Pages: totalPages, - }, + Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 0113cee..29e849a 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -177,11 +177,15 @@ func (h *TenderHandler) Search(c echo.Context) error { return response.ValidationError(c, "Validation failed", err.Error()) } - // Call service - tenders, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { - if isInvalidPaginationCursor(err) { - return response.ValidationError(c, "Invalid pagination cursor", err.Error()) + return response.PaginationBadRequest(c, err) + } + + tenders, err := h.service.Search(c.Request().Context(), form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) } return response.InternalServerError(c, "Failed to list tenders") } @@ -259,10 +263,15 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error { } form.CustomerID = customerID - result, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { - if isInvalidPaginationCursor(err) { - return response.ValidationError(c, "Invalid pagination cursor", err.Error()) + return response.PaginationBadRequest(c, err) + } + + result, err := h.service.Search(c.Request().Context(), form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) } return response.InternalServerError(c, "Failed to retrieve tenders") } @@ -335,10 +344,15 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { } form.CustomerID = customerID - result, err := h.service.Recommend(c.Request().Context(), form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { - if isInvalidPaginationCursor(err) { - return response.ValidationError(c, "Invalid pagination cursor", err.Error()) + return response.PaginationBadRequest(c, err) + } + + result, err := h.service.Recommend(c.Request().Context(), form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) } return response.InternalServerError(c, "Failed to retrieve tenders") } @@ -664,6 +678,3 @@ func (h *TenderHandler) GetAllAISummaries(c echo.Context) error { return response.Success(c, summaries, "AI summaries retrieved successfully") } -func isInvalidPaginationCursor(err error) bool { - return errors.Is(err, orm.ErrInvalidCursor) -} diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 5e72f15..386f585 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -40,8 +40,6 @@ func collectionName() string { return "tenders" } -// deepOffsetSkipCountThreshold skips CountDocuments when offset pagination goes this deep. -const deepOffsetSkipCountThreshold = 1000 // SearchPageResult is the paginated outcome of a tender search/list query. type SearchPageResult struct { @@ -49,6 +47,7 @@ type SearchPageResult struct { TotalCount int64 // -1 when count was skipped NextCursor string HasMore bool + PageOffset int } // tenderSearchListProjection excludes large fields not needed for list/search API payloads. @@ -370,21 +369,20 @@ func (r *tenderRepository) Search(ctx context.Context, form *SearchForm, paginat sortOrder = pagination.SortOrder } - skipCount := pagination.Cursor != "" || pagination.Offset >= deepOffsetSkipCountThreshold - - paginationBuilder := orm.NewPaginationBuilder(). - Limit(pagination.Limit). - SortBy(sort, sortOrder). - Projection(tenderSearchListProjection()). - SkipCount(skipCount) - - if pagination.Cursor != "" { - paginationBuilder.Cursor(pagination.Cursor) - } else { - paginationBuilder.Skip(pagination.Offset) + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + sort, + sortOrder, + filter, + orm.ListPaginationOptions{Projection: tenderSearchListProjection()}, + ) + if err != nil { + return nil, err } - result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder.Build()) + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to search tenders", map[string]interface{}{ "error": err.Error(), @@ -397,6 +395,7 @@ func (r *tenderRepository) Search(ctx context.Context, form *SearchForm, paginat TotalCount: result.TotalCount, NextCursor: result.NextCursor, HasMore: result.HasMore, + PageOffset: result.PageOffset, }, nil } diff --git a/internal/tender/service.go b/internal/tender/service.go index 280ea32..60c4e0e 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -648,7 +648,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination return &SearchResponse{ Tenders: tenderResponses, - Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), + Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } @@ -667,7 +667,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination return &SearchResponse{ Tenders: tenderResponses, - Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), + Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } @@ -690,7 +690,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination return &SearchResponse{ Tenders: tenderResponses, - Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), + Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } @@ -757,7 +757,7 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat return &SearchResponse{ Tenders: tenderResponses, - Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), + Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } diff --git a/internal/user/handler.go b/internal/user/handler.go index 1053858..355b2cc 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -311,8 +311,16 @@ func (h *Handler) ListUsers(c echo.Context) error { } // Call service - users, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) + pagination, err := response.NewPagination(c) if err != nil { + return response.PaginationBadRequest(c, err) + } + + users, err := h.service.Search(c.Request().Context(), form, pagination) + if err != nil { + if response.IsListPaginationError(err) { + return response.PaginationBadRequest(c, err) + } return response.InternalServerError(c, "Failed to list users") } diff --git a/internal/user/repository.go b/internal/user/repository.go index 03ac190..ebf118e 100644 --- a/internal/user/repository.go +++ b/internal/user/repository.go @@ -22,7 +22,7 @@ type Repository interface { GetByEmail(ctx context.Context, email string) (*User, error) GetByUsername(ctx context.Context, username string) (*User, error) GetByIDs(ctx context.Context, userIDs []string) ([]User, error) - Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) ([]User, int64, error) + Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*orm.PaginatedResult[User], error) } // userRepository implements the Repository interface using the MongoDB ORM @@ -280,8 +280,8 @@ func (r *userRepository) GetByIDs(ctx context.Context, userIDs []string) ([]User return users.Items, nil } -// List retrieves users with pagination -func (r *userRepository) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) ([]User, int64, error) { +// Search retrieves users with pagination +func (r *userRepository) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*orm.PaginatedResult[User], error) { filter := bson.M{} if form.Search != nil { @@ -300,32 +300,26 @@ func (r *userRepository) Search(ctx context.Context, form *SearchUsersForm, pagi filter["role"] = form.Role } - // Build sort - sort := "created_at" - if pagination.SortBy != "" { - sort = pagination.SortBy + mongoPagination, err := orm.BuildListPagination( + pagination.Limit, + pagination.Offset, + pagination.Cursor, + pagination.SortBy, + pagination.SortOrder, + filter, + orm.ListPaginationOptions{}, + ) + if err != nil { + return nil, err } - sortOrder := "desc" - if pagination.SortOrder != "" { - sortOrder = pagination.SortOrder - } - - // Build pagination - paginationBuilder := orm.NewPaginationBuilder(). - Limit(pagination.Limit). - Skip(pagination.Offset). - SortBy(sort, sortOrder). - Build() - - // Use ORM to find users - result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder) + result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination) if err != nil { r.logger.Error("Failed to list users", map[string]interface{}{ "error": err.Error(), }) - return nil, 0, err + return nil, err } - return result.Items, result.TotalCount, nil + return result, nil } diff --git a/internal/user/service.go b/internal/user/service.go index 20d6e20..95565c1 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -278,23 +278,22 @@ func (s *userService) GetUsersByIDs(ctx context.Context, userIDs []string) (*Use // Search searches users with search and filters func (s *userService) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error) { // Get users - users, total, err := s.repository.Search(ctx, form, pagination) + page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to list users", map[string]interface{}{ "error": err.Error(), }) - return nil, errors.New("failed to list users") + return nil, err } - // Convert to responses var userResponses []*UserResponse - for _, user := range users { + for _, user := range page.Items { userResponses = append(userResponses, user.ToResponse()) } return &UserListResponse{ Users: userResponses, - Meta: pagination.Response(total), + Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } diff --git a/pkg/mongo/cursor.go b/pkg/mongo/cursor.go index 6823576..6cb0c38 100644 --- a/pkg/mongo/cursor.go +++ b/pkg/mongo/cursor.go @@ -13,15 +13,17 @@ const pageCursorVersion = 1 // pageCursor is an opaque pagination token for keyset (cursor) pagination. type pageCursor struct { - V int `json:"v"` - SortField string `json:"sort_field"` - SortOrder int `json:"sort_order"` - ValueType string `json:"value_type"` // i64, f64, str, oid - SortValue string `json:"sort_value"` - ID string `json:"id"` + V int `json:"v"` + SortField string `json:"sort_field"` + SortOrder int `json:"sort_order"` + ValueType string `json:"value_type"` // i64, f64, str, oid + SortValue string `json:"sort_value"` + ID string `json:"id"` + FiltersHash string `json:"filters_hash,omitempty"` + PageOffset int `json:"page_offset,omitempty"` } -func encodePageCursor(sortField string, sortOrder int, sortValue interface{}, id string) (string, error) { +func encodePageCursor(sortField string, sortOrder int, sortValue interface{}, id, filtersHash string, pageOffset int) (string, error) { if sortField == "" { return "", fmt.Errorf("%w: sort field is required", ErrInvalidCursor) } @@ -35,22 +37,28 @@ func encodePageCursor(sortField string, sortOrder int, sortValue interface{}, id } payload, err := json.Marshal(pageCursor{ - V: pageCursorVersion, - SortField: sortField, - SortOrder: sortOrder, - ValueType: valueType, - SortValue: sortValueStr, - ID: id, + V: pageCursorVersion, + SortField: sortField, + SortOrder: sortOrder, + ValueType: valueType, + SortValue: sortValueStr, + ID: id, + FiltersHash: filtersHash, + PageOffset: pageOffset, }) if err != nil { return "", fmt.Errorf("failed to marshal cursor: %w", err) } - return base64.StdEncoding.EncodeToString(payload), nil + return base64.RawURLEncoding.EncodeToString(payload), nil } func decodePageCursor(encoded string) (*pageCursor, error) { - cursorBytes, err := base64.StdEncoding.DecodeString(encoded) + cursorBytes, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + // Accept standard Base64 cursors issued before URL-safe encoding. + cursorBytes, err = base64.StdEncoding.DecodeString(encoded) + } if err != nil { return nil, fmt.Errorf("%w: invalid cursor encoding", ErrInvalidCursor) } diff --git a/pkg/mongo/cursor_test.go b/pkg/mongo/cursor_test.go new file mode 100644 index 0000000..fad37c4 --- /dev/null +++ b/pkg/mongo/cursor_test.go @@ -0,0 +1,55 @@ +package mongo + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestEncodeDecodePageCursor(t *testing.T) { + encoded, err := encodePageCursor("created_at", -1, int64(1700000000), "507f1f77bcf86cd799439011", "abc123", 10) + if err != nil { + t.Fatalf("encode: %v", err) + } + if strings.Contains(encoded, "+") || strings.Contains(encoded, "/") { + t.Fatalf("expected URL-safe cursor, got %q", encoded) + } + + decoded, err := decodePageCursor(encoded) + if err != nil { + t.Fatalf("decode: %v", err) + } + + if decoded.SortField != "created_at" || decoded.SortOrder != -1 { + t.Fatalf("unexpected sort: %+v", decoded) + } + if decoded.FiltersHash != "abc123" || decoded.PageOffset != 10 { + t.Fatalf("unexpected metadata: %+v", decoded) + } +} + +func TestBuildListPaginationCursorAndOffsetConflict(t *testing.T) { + _, err := BuildListPagination(10, 5, "cursor-token", "created_at", "desc", bson.M{}, ListPaginationOptions{}) + if err == nil { + t.Fatal("expected error when cursor and offset are both set") + } +} + +func TestBuildListPaginationOffsetMode(t *testing.T) { + p, err := BuildListPagination(10, 20, "", "created_at", "desc", bson.M{"status": "active"}, ListPaginationOptions{}) + if err != nil { + t.Fatalf("build: %v", err) + } + if p.Skip != 20 || p.Limit != 10 || p.Cursor != "" { + t.Fatalf("unexpected pagination: %+v", p) + } +} + +func TestHashFilterStable(t *testing.T) { + a := HashFilter(bson.M{"status": "active"}) + b := HashFilter(bson.M{"status": "active"}) + if a == "" || a != b { + t.Fatalf("expected stable non-empty hash, got %q and %q", a, b) + } +} diff --git a/pkg/mongo/filter_hash.go b/pkg/mongo/filter_hash.go new file mode 100644 index 0000000..62ad1e1 --- /dev/null +++ b/pkg/mongo/filter_hash.go @@ -0,0 +1,21 @@ +package mongo + +import ( + "crypto/sha256" + "encoding/hex" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// HashFilter returns a stable SHA-256 hex digest of a MongoDB filter for cursor binding. +func HashFilter(filter bson.M) string { + if len(filter) == 0 { + return "" + } + raw, err := bson.Marshal(filter) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} diff --git a/pkg/mongo/list_pagination.go b/pkg/mongo/list_pagination.go new file mode 100644 index 0000000..d7c778f --- /dev/null +++ b/pkg/mongo/list_pagination.go @@ -0,0 +1,78 @@ +package mongo + +import ( + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + // DefaultListLimit is the default page size for admin list endpoints. + DefaultListLimit = 10 + // MaxListLimit is the maximum allowed page size. + MaxListLimit = 100 +) + +// ListPaginationOptions configures optional list pagination behavior. +type ListPaginationOptions struct { + Projection bson.M +} + +// BuildListPagination converts API pagination parameters into MongoDB pagination options. +// filter is used to bind cursors to the active query filters via a hash. +func BuildListPagination( + limit, offset int, + cursor, sortField, sortOrder string, + filter bson.M, + opts ListPaginationOptions, +) (Pagination, error) { + if limit <= 0 { + limit = DefaultListLimit + } + if limit > MaxListLimit { + return Pagination{}, fmt.Errorf("%w: limit must be between 1 and %d", ErrInvalidPagination, MaxListLimit) + } + + if sortField == "" { + sortField = "created_at" + } + if sortOrder == "" { + sortOrder = "desc" + } + + sortOrderInt := -1 + if sortOrder == "asc" { + sortOrderInt = 1 + } + + filtersHash := HashFilter(filter) + + pb := NewPaginationBuilder(). + Limit(limit). + SortBy(sortField, sortOrder) + + if len(opts.Projection) > 0 { + pb.Projection(opts.Projection) + } + + if cursor != "" { + pageCursor, err := decodePageCursor(cursor) + if err != nil { + return Pagination{}, err + } + if !pageCursor.matches(sortField, sortOrderInt) { + return Pagination{}, fmt.Errorf("%w: cursor does not match requested sort", ErrInvalidCursor) + } + if pageCursor.FiltersHash != filtersHash { + return Pagination{}, fmt.Errorf("%w: cursor does not match current filters", ErrInvalidCursor) + } + pb.Cursor(cursor) + } else { + if offset < 0 { + return Pagination{}, fmt.Errorf("%w: offset cannot be negative", ErrInvalidPagination) + } + pb.Skip(offset) + } + + return pb.Build(), nil +} diff --git a/pkg/mongo/repository.go b/pkg/mongo/repository.go index b9dffbf..9a96086 100644 --- a/pkg/mongo/repository.go +++ b/pkg/mongo/repository.go @@ -37,6 +37,7 @@ type PaginatedResult[T any] struct { TotalCount int64 `json:"totalCount"` // Total number of documents matching the filter NextCursor string `json:"nextCursor"` // Base64 encoded cursor for next page HasMore bool `json:"hasMore"` // Whether there are more documents + PageOffset int `json:"pageOffset"` // Zero-based offset of the first item in this page } // Repository defines the interface for MongoDB operations @@ -144,6 +145,16 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P return nil, fmt.Errorf("failed to clone filter for count: %w", err) } + filtersHash := HashFilter(filter) + pageOffset := pagination.Skip + if pagination.Cursor != "" { + pageCursor, err := decodePageCursor(pagination.Cursor) + if err != nil { + return nil, err + } + pageOffset = pageCursor.PageOffset + } + // Build find options findOptions := options.Find(). SetSort(sort). @@ -155,7 +166,7 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P // Handle cursor-based pagination if pagination.Cursor != "" { - cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder) + cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder, filtersHash) if err != nil { return nil, err } @@ -209,12 +220,13 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P // Generate next cursor var nextCursor string if hasMore && len(items) > 0 { - nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField, pagination.SortOrder) + nextPageOffset := pageOffset + pagination.Limit + nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField, pagination.SortOrder, filtersHash, nextPageOffset) if err != nil { r.logger.Error("Failed to generate next cursor", map[string]interface{}{ "error": err.Error(), }) - // Don't fail the entire query, just leave cursor empty + return nil, fmt.Errorf("failed to generate pagination cursor: %w", err) } } @@ -223,6 +235,7 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P TotalCount: totalCount, NextCursor: nextCursor, HasMore: hasMore, + PageOffset: pageOffset, }, nil } @@ -454,7 +467,7 @@ func (r *repository[T]) validatePagination(pagination Pagination) error { } // buildCursorFilter builds a filter for cursor-based pagination. -func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int) (bson.M, error) { +func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int, filtersHash string) (bson.M, error) { pageCursor, err := decodePageCursor(cursor) if err != nil { return nil, err @@ -462,11 +475,14 @@ func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder in if !pageCursor.matches(sortField, sortOrder) { return nil, fmt.Errorf("%w: cursor does not match requested sort", ErrInvalidCursor) } + if pageCursor.FiltersHash != filtersHash { + return nil, fmt.Errorf("%w: cursor does not match current filters", ErrInvalidCursor) + } return pageCursor.mongoFilter() } // generateCursor generates an opaque cursor from the last document in a page. -func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int) (string, error) { +func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int, filtersHash string, pageOffset int) (string, error) { docBytes, err := bson.Marshal(doc) if err != nil { return "", fmt.Errorf("failed to marshal document: %w", err) @@ -487,7 +503,7 @@ func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int) ( return "", err } - return encodePageCursor(sortField, sortOrder, sortValue, idHex) + return encodePageCursor(sortField, sortOrder, sortValue, idHex, filtersHash, pageOffset) } // getModelID extracts the ID from a model diff --git a/pkg/response/pagination.go b/pkg/response/pagination.go new file mode 100644 index 0000000..bf8b4f1 --- /dev/null +++ b/pkg/response/pagination.go @@ -0,0 +1,105 @@ +package response + +import ( + "errors" + "fmt" + "strconv" + + "github.com/labstack/echo/v4" +) + +const ( + // DefaultListLimit is the default page size for admin list endpoints. + DefaultListLimit = 10 + // MaxListLimit is the maximum allowed page size. + MaxListLimit = 100 +) + +var ( + // ErrInvalidPagination is returned when pagination query parameters are invalid. + ErrInvalidPagination = errors.New("invalid pagination parameters") + // ErrPaginationConflict is returned when cursor and offset are both supplied. + ErrPaginationConflict = errors.New("cursor and offset pagination cannot be used together") +) + +// NewPagination parses and validates hybrid pagination query parameters from the request. +func NewPagination(c echo.Context) (*Pagination, error) { + limit := DefaultListLimit + if limitStr := c.QueryParam("limit"); limitStr != "" { + parsed, err := strconv.Atoi(limitStr) + if err != nil { + return nil, fmt.Errorf("%w: limit must be an integer", ErrInvalidPagination) + } + limit = parsed + } + if limit < 1 || limit > MaxListLimit { + return nil, fmt.Errorf("%w: limit must be between 1 and %d", ErrInvalidPagination, MaxListLimit) + } + + cursor := c.QueryParam("cursor") + offsetParam := c.QueryParam("offset") + if cursor != "" && offsetParam != "" { + return nil, ErrPaginationConflict + } + + offset := 0 + if offsetParam != "" { + parsed, err := strconv.Atoi(offsetParam) + if err != nil { + return nil, fmt.Errorf("%w: offset must be an integer", ErrInvalidPagination) + } + if parsed < 0 { + return nil, fmt.Errorf("%w: offset cannot be negative", ErrInvalidPagination) + } + offset = parsed + } + + sortBy := c.QueryParam("sort_by") + if sortBy == "" { + sortBy = "created_at" + } + + sortOrder := c.QueryParam("sort_order") + if sortOrder == "" { + sortOrder = "desc" + } + + return &Pagination{ + Limit: limit, + Offset: offset, + Cursor: cursor, + SortBy: sortBy, + SortOrder: sortOrder, + }, nil +} + +// ListMeta builds pagination metadata for a list page. +// pageOffset is the zero-based index of the first item in this page (from offset or cursor). +func (p *Pagination) ListMeta(total int64, nextCursor string, hasMore bool, pageOffset int) *Meta { + meta := &Meta{ + Limit: p.Limit, + Offset: pageOffset, + HasMore: hasMore, + } + if hasMore && nextCursor != "" { + meta.NextCursor = nextCursor + } + if total >= 0 { + meta.Total = int(total) + if p.Limit > 0 { + meta.Page = pageOffset/p.Limit + 1 + meta.Pages = int((total + int64(p.Limit) - 1) / int64(p.Limit)) + } + } + return meta +} + +// ListMetaFromPage builds metadata from a paginated repository result. +func (p *Pagination) ListMetaFromPage(total int64, nextCursor string, hasMore bool, pageOffset int) *Meta { + return p.ListMeta(total, nextCursor, hasMore, pageOffset) +} + +// IsPaginationError reports whether err is a client-facing pagination error. +func IsPaginationError(err error) bool { + return errors.Is(err, ErrInvalidPagination) || errors.Is(err, ErrPaginationConflict) +} diff --git a/pkg/response/pagination_test.go b/pkg/response/pagination_test.go new file mode 100644 index 0000000..1956ecb --- /dev/null +++ b/pkg/response/pagination_test.go @@ -0,0 +1,47 @@ +package response + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" +) + +func TestNewPaginationDefaults(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/?limit=10&offset=0&sort_by=created_at&sort_order=desc", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + p, err := NewPagination(c) + if err != nil { + t.Fatalf("NewPagination: %v", err) + } + if p.Limit != 10 || p.Offset != 0 || p.Cursor != "" { + t.Fatalf("unexpected pagination: %+v", p) + } +} + +func TestNewPaginationCursorConflict(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/?limit=10&offset=5&cursor=abc", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + _, err := NewPagination(c) + if !IsPaginationError(err) { + t.Fatalf("expected pagination conflict, got %v", err) + } +} + +func TestListMetaOmitsNextCursorOnLastPage(t *testing.T) { + p := &Pagination{Limit: 10, Offset: 0} + meta := p.ListMeta(15, "should-not-appear", false, 0) + if meta.HasMore || meta.NextCursor != "" { + t.Fatalf("expected no next cursor on last page: %+v", meta) + } + if meta.Page != 1 || meta.Pages != 2 || meta.Total != 15 { + t.Fatalf("unexpected page meta: %+v", meta) + } +} diff --git a/pkg/response/response.go b/pkg/response/response.go index a058e50..34ccd21 100644 --- a/pkg/response/response.go +++ b/pkg/response/response.go @@ -1,11 +1,13 @@ package response import ( + "errors" "net/http" - "strconv" "github.com/asaskevich/govalidator" "github.com/labstack/echo/v4" + + orm "tm/pkg/mongo" ) // APIResponse represents a standard API response structure @@ -26,17 +28,17 @@ type APIError struct { // Meta represents metadata for paginated responses type Meta struct { - Total int `json:"total,omitempty"` + Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` - Page int `json:"page,omitempty"` - Pages int `json:"pages,omitempty"` + Page int `json:"page"` + Pages int `json:"pages"` + HasMore bool `json:"has_more"` NextCursor string `json:"next_cursor,omitempty"` - HasMore bool `json:"has_more,omitempty"` } type Pagination struct { - Limit int `query:"limit" valid:"optional,range(1|100)" default:"20"` + Limit int `query:"limit" valid:"optional,range(1|100)" default:"10"` Offset int `query:"offset" valid:"optional,range(0|1000000)" default:"0"` Cursor string `query:"cursor" valid:"optional"` SortBy string `query:"sort_by" valid:"optional"` @@ -192,59 +194,16 @@ func Parse[T any](c echo.Context) (*T, error) { return &form, nil } -func NewPagination(c echo.Context) *Pagination { - limit, err := strconv.Atoi(c.QueryParam("limit")) - if err != nil { - limit = 20 - } - - offset, err := strconv.Atoi(c.QueryParam("offset")) - if err != nil { - offset = 0 - } - - sortBy := c.QueryParam("sort_by") - if sortBy == "" { - sortBy = "created_at" - } - - sortOrder := c.QueryParam("sort_order") - if sortOrder == "" { - sortOrder = "desc" - } - - cursor := c.QueryParam("cursor") - - return &Pagination{ - Limit: limit, - Offset: offset, - Cursor: cursor, - SortBy: sortBy, - SortOrder: sortOrder, +// PaginationBadRequest writes a 400 response for invalid list pagination parameters. +func PaginationBadRequest(c echo.Context, err error) error { + msg := "Invalid pagination parameters" + if errors.Is(err, orm.ErrInvalidCursor) { + msg = "Invalid pagination cursor" } + return BadRequest(c, msg, err.Error()) } -// Response calculates offset-based pagination metadata. -func (p *Pagination) Response(total int64) *Meta { - return p.ListMeta(total, "", false) -} - -// ListMeta builds pagination metadata for list endpoints (offset and/or cursor). -// When total is negative, total/page/pages are omitted (count was skipped). -func (p *Pagination) ListMeta(total int64, nextCursor string, hasMore bool) *Meta { - meta := &Meta{ - Limit: p.Limit, - Offset: p.Offset, - NextCursor: nextCursor, - HasMore: hasMore, - } - if total >= 0 { - pages := (total + int64(p.Limit) - 1) / int64(p.Limit) - meta.Total = int(total) - if p.Limit > 0 { - meta.Page = p.Offset/p.Limit + 1 - } - meta.Pages = int(pages) - } - return meta +// IsListPaginationError reports client-facing pagination errors from list queries. +func IsListPaginationError(err error) bool { + return IsPaginationError(err) || errors.Is(err, orm.ErrInvalidCursor) || errors.Is(err, orm.ErrInvalidPagination) }