Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166]) by code.netlandish.com (Postfix) with ESMTP id 624C9270 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 03 Apr 2025 13:29:13 +0000 (UTC) Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.222.51; helo=mail-ua1-f51.google.com; envelope-from=peter@netlandish.com; receiver= Authentication-Results: mail.netlandish.com; dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=KDPsZuGF Received: from mail-ua1-f51.google.com (mail-ua1-f51.google.com [209.85.222.51]) by mail.netlandish.com (Postfix) with ESMTP id B2E031D642E for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 03 Apr 2025 13:29:16 +0000 (UTC) Received: by mail-ua1-f51.google.com with SMTP id a1e0cc1a2514c-86fab198f8eso485523241.1 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 03 Apr 2025 06:29:16 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=netlandish.com; s=google; t=1743686956; x=1744291756; darn=lists.code.netlandish.com; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:from:to:cc:subject:date:message-id:reply-to; bh=d2+ugD0rbxKGIo8uB9mtnInU9wnH7NyNNSrR6s8Larc=; b=KDPsZuGFvJpLQuLbiPPA0+lDToOxCGHV9u7eVhrTAPayo5zf7a9GNtMZ6pBVzWIgq9 xzxkfkcGTTOKmiRA+XNgAGMZjbL46IU1Poew6E563wgau+dVzO+qVM7qDlyobh/jLGHa FJtQAB4RRpI2mDnj2O6dG5YTSpA5X/3tpQx9Y= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1743686956; x=1744291756; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=d2+ugD0rbxKGIo8uB9mtnInU9wnH7NyNNSrR6s8Larc=; b=CmL5IbxWu0vTDFO0Jirfp/r1BY8araFUSE7SS7zlZdApv7tzR7KUGqeb0SrcnzNlrW s14YK7TU2kDApnrP9+QVRLuCvg6Yt0F8uDvVv1w6Nz48E+77Z07LCoW7tL3zbF/tIeTl mg9qImMEu0bH54LuzbqE1xk8pQyRUmLuTI1S79izMc+MkdeTTLuGJ0Ok4hmBlwjxKaWN 30bgwyVwB8zI6RbIdn9HBD4h8WQefzQRroHsVp7lmRzHziCUAnY4VpQUX67cHFP5K6zn 2EJNwQr4bqJg7i371pcXaHvJKXxj2xIevWtOp0HNdifYT6qNhVB/u3SQC/zwAv0vyCod dJ6A== X-Gm-Message-State: AOJu0Yx4K83ZESk84d9c11YqRDIB1++9lAvq67wqfUGPm8SHkZerAmUQ jE9815EJuq/5ZIOmBGHdnbm5n17lVzpoGmvf+ttbg3VEzYLR8Q3ti4+DFg+USo55vp5yAm0HGLM LVuQ= X-Gm-Gg: ASbGnctdXeCjMP4fVfJLawjhxxXstTXlEKIpnQHVraOfZ+2UhUKAksxtZ+5UHA9tX4A 6PQuh72XEjDEWgrAt4A2lp9+jFpTjcod4zLQgOaLBPYEUBIkZBle40CfUuwIYGpMvRaPyL4UEeT j16rTFFCkhnywQroVapHOFqhQiGF5wpUwEwZWSIpzOeRGL/BHIeRzL4GLZ+WAAB/LSBFICSltzR 1CTa7knL6Mlv6rDrkinIW+1c5OslGuyAOV+7XO4I4BAqV1efoDDX8idOyKOEpUICqfoqWwaKvq+ /yfu+prpF8ht8EDKDTuU2PGnARunC6jZBAIA8MTQ+vT/9g== X-Google-Smtp-Source: AGHT+IE1qWuXTQvoEpERp/cquW/AGrPTOt2Ixby0cjwRdSkD4tSpMlkF+OIJvVLZySadgKstjUw2qg== X-Received: by 2002:a05:6102:504b:b0:4c3:b0:46fd with SMTP id ada2fe7eead31-4c8478c5e66mr2035458137.24.1743686955138; Thu, 03 Apr 2025 06:29:15 -0700 (PDT) Received: from localhost ([2803:2d60:1107:87f:2444:1802:2e41:4562]) by smtp.gmail.com with ESMTPSA id ada2fe7eead31-4c849539b47sm260280137.24.2025.04.03.06.29.13 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Thu, 03 Apr 2025 06:29:14 -0700 (PDT) From: Peter Sanchez To: ~netlandish/links-dev@lists.code.netlandish.com Cc: Peter Sanchez Subject: [PATCH links] Fixing "wonky" previous (back) pagination issues. Date: Thu, 3 Apr 2025 07:27:41 -0600 Message-ID: <20250403132911.5861-1-peter@netlandish.com> X-Mailer: git-send-email 2.47.2 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Created generic functions to query correctly and also paginate correctly given the pagination needs and requirements for this project. Fixes: https://todo.code.netlandish.com/~netlandish/links/102 Changelog-added: Generic functions to fetch data and paginate correctly so all views that need pagination can easily plug it in. --- This should resolve the weirdness when paginating via "prev" links throughout the site. Also there was a lot of inconsistencies in the pagination. Sometimes the ordering was wrong on display even though the data was returned correctly (needed reversing, etc.). I've made it all uniform and use the same generic functions to keep everything in sync. Some more tweaks are probably needed in the future to customize sort ordering, etc. admin/routes.go | 15 + api/graph/pagination.go | 81 +++ api/graph/pagination_test.go | 77 +++ api/graph/schema.resolvers.go | 942 ++++++---------------------------- billing/routes.go | 5 + core/routes.go | 8 + list/routes.go | 8 + mattermost/routes.go | 6 - short/routes.go | 4 + 9 files changed, 354 insertions(+), 792 deletions(-) create mode 100644 api/graph/pagination.go create mode 100644 api/graph/pagination_test.go diff --git a/admin/routes.go b/admin/routes.go index 1e730b1..d6f30a8 100644 --- a/admin/routes.go +++ b/admin/routes.go @@ -7,6 +7,7 @@ import ( "links/internal/localizer" "links/models" "net/http" + "slices" "strconv" "strings" "time" @@ -446,6 +447,10 @@ func (s *Service) OrgDetail(c echo.Context) error { return err } + if c.QueryParam("prev") != "" { + slices.Reverse(historyResult.Payments.Result) + } + type GraphQLStats struct { Stats struct { Links int `json:"links"` @@ -1005,6 +1010,10 @@ func (s *Service) BillingList(c echo.Context) error { return err } + if c.QueryParam("prev") != "" { + slices.Reverse(historyResult.Payments.Result) + } + gmap := gobwebs.Map{ "pd": pd, "navFlag": "admin", @@ -1164,6 +1173,9 @@ func (s *Service) DomainList(c echo.Context) error { if err != nil { return err } + if c.QueryParam("prev") != "" { + slices.Reverse(result.Organizations.Result) + } if result.Organizations.PageInfo.HasPrevPage { gmap["prevURL"] = links.GetPaginationParams(c, "prev", result.Organizations.PageInfo.Cursor, "next") } @@ -1630,6 +1642,9 @@ func (s *Service) UserList(c echo.Context) error { if err != nil { return err } + if c.QueryParam("prev") != "" { + slices.Reverse(result.GetUsers.Result) + } if result.GetUsers.PageInfo.HasPrevPage { gmap["prevURL"] = links.GetPaginationParams(c, "prev", result.GetUsers.PageInfo.Cursor, "next") } diff --git a/api/graph/pagination.go b/api/graph/pagination.go new file mode 100644 index 0000000..82043a4 --- /dev/null +++ b/api/graph/pagination.go @@ -0,0 +1,81 @@ +package graph + +import ( + "context" + "links/api/graph/model" + + sq "github.com/Masterminds/squirrel" + "netlandish.com/x/gobwebs/database" +) + +func PaginateResults[T any](items []T, limit int, before, after *model.Cursor, + getID func(T) int) ([]T, *model.PageInfo) { + overFetched := len(items) > limit + if overFetched { + items = items[:limit] + } + + var pageInfo model.PageInfo + if len(items) > 0 { + pageInfo.Cursor.Limit = limit + pageInfo.Cursor.Before = getID(items[0]) + pageInfo.Cursor.After = getID(items[len(items)-1]) + } + + if before != nil { + pageInfo.HasPrevPage = overFetched + pageInfo.HasNextPage = true + pageInfo.Cursor.Before, pageInfo.Cursor.After = + pageInfo.Cursor.After, pageInfo.Cursor.Before + } else if after != nil { + pageInfo.HasPrevPage = true + pageInfo.HasNextPage = overFetched + } else { + pageInfo.HasNextPage = overFetched + } + + return items, &pageInfo +} + +func QueryModel[T any]( + ctx context.Context, + opts *database.FilterOptions, + idField string, + limit *int, + before, after *model.Cursor, + getModels func(context.Context, *database.FilterOptions) ([]T, error), + getID func(T) int, +) ([]T, *model.PageInfo, error) { + numElements := model.PaginationDefault + if after != nil { + opts.Filter = sq.And{ + opts.Filter, + sq.Lt{idField: after.After}, + } + numElements = after.Limit + } else if before != nil { + opts.Filter = sq.And{ + opts.Filter, + sq.Gt{idField: before.Before}, + } + opts.OrderBy = idField + " ASC" + numElements = before.Limit + } + + // If limit specifically set, it overrides any cursor limit + if limit != nil && *limit > 0 { + numElements = *limit + } + if numElements > model.PaginationMax { + numElements = model.PaginationMax + } + + opts.Limit = numElements + 1 + items, err := getModels(ctx, opts) + if err != nil { + return nil, nil, err + } + + items, pageInfo := PaginateResults(items, numElements, before, after, getID) + return items, pageInfo, nil +} diff --git a/api/graph/pagination_test.go b/api/graph/pagination_test.go new file mode 100644 index 0000000..82e7500 --- /dev/null +++ b/api/graph/pagination_test.go @@ -0,0 +1,77 @@ +package graph + +import ( + "links/api/graph/model" + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPaginationForwardAndBackward(t *testing.T) { + var allItems []int + for i := 126; i >= 101; i-- { + allItems = append(allItems, i) + } + + getPage := func(after, before *model.Cursor) ([]int, *model.PageInfo) { + var filtered []int + for _, id := range allItems { + // ORDER BY id DESC + if after != nil && id >= after.After { + continue + } + // ORDER BY id ASC + if before != nil && id <= before.Before { + continue + } + filtered = append(filtered, id) + } + if before != nil { + slices.Reverse(filtered) + } + return PaginateResults(filtered, 5, before, after, func(v int) int { + return v + }) + } + + t.Run("Forward Pagination", func(t *testing.T) { + var after *model.Cursor + seen := make(map[int]bool) + for { + page, info := getPage(after, nil) + // t.Logf("Backward Page: %v", page) + for _, id := range page { + if seen[id] { + t.Fatalf("duplicate forward id %d", id) + } + seen[id] = true + } + if !info.HasNextPage { + break + } + after = &model.Cursor{After: info.Cursor.After, Limit: 5} + } + assert.Equal(t, 26, len(seen)) + }) + + t.Run("Backward Pagination", func(t *testing.T) { + before := &model.Cursor{Before: 0} + seen := make(map[int]bool) + for { + page, info := getPage(nil, before) + // t.Logf("Backward Page: %v", page) + for _, id := range page { + if seen[id] { + t.Fatalf("duplicate backward id %d", id) + } + seen[id] = true + } + if !info.HasPrevPage { + break + } + before = &model.Cursor{Before: info.Cursor.Before, Limit: 5} + } + assert.Equal(t, 26, len(seen)) + }) +} diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index a00cc26..78e264e 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -27,6 +27,7 @@ import ( "net/url" "os" "regexp" + "slices" "strconv" "strings" "time" @@ -53,7 +54,7 @@ import ( ) // Metadata is the resolver for the metadata field. -func (r *auditLogResolver) Metadata(ctx context.Context, obj *auditlog.AuditLog) (map[string]interface{}, error) { +func (r *auditLogResolver) Metadata(ctx context.Context, obj *auditlog.AuditLog) (map[string]any, error) { return obj.Metadata, nil } @@ -1832,11 +1833,8 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi WithCode(valid.ErrValidationCode) var validTZ bool - for _, tz := range timezone.GetTZList() { - if input.Timezone == tz { - validTZ = true - break - } + if slices.Contains(timezone.GetTZList(), input.Timezone) { + validTZ = true } validator.Expect(validTZ, "%s", lt.Translate("Timezone is invalid")). @@ -1848,11 +1846,8 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi WithCode(valid.ErrValidationCode) var validLang bool - for _, lang := range localizer.GetLangList() { - if input.DefaultLang == lang { - validLang = true - break - } + if slices.Contains(localizer.GetLangList(), input.DefaultLang) { + validLang = true } validator.Expect(validLang, "%s", lt.Translate("Lang is invalid")). @@ -4886,10 +4881,6 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user) lt := localizer.GetLocalizer(lang) - opts := &database.FilterOptions{ - Filter: sq.And{}, Limit: 1, - } - validator := valid.New(ctx) // If you are not super user we have to validate // org permission to see the payment history @@ -4899,6 +4890,17 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP return nil, nil } + if input.After != nil && input.Before != nil { + validator.Error("%s", lt.Translate("You can not send both after and before cursors")). + WithCode(valid.ErrValidationGlobalCode) + return nil, nil + } + + opts := &database.FilterOptions{ + Filter: sq.And{}, + Limit: 1, + } + var org *models.Organization if input.OrgSlug != nil { opts.Filter = sq.And{ @@ -4926,7 +4928,7 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP opts = &database.FilterOptions{ Filter: sq.And{}, - OrderBy: "i.created_on DESC", + OrderBy: "i.id DESC", } if org != nil { @@ -4983,91 +4985,22 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP } } - // Pagination - if input.After != nil && input.Before != nil { - validator.Error("%s", lt.Translate("You can not send both after and before cursors")). - WithCode(valid.ErrValidationGlobalCode) - return nil, nil - } - numElements := model.PaginationDefault - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.LtOrEq{"i.id": input.After.After}, - } - numElements = input.After.Limit - } else if input.Before != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.GtOrEq{"i.id": input.Before.Before}, - } - opts.OrderBy = "i.id ASC" - numElements = input.Before.Limit - } - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - opts.Limit = numElements + 2 - invoices, err := models.GetInvoices(ctx, opts) + invoices, pageInfo, err := QueryModel( + ctx, + opts, + "i.id", + input.Limit, + input.Before, + input.After, + models.GetInvoices, + func(x *models.Invoice) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(invoices) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if int(invoices[0].ID) == input.Before.Before { - hasPrevPage = true - invoices = invoices[1:] - count-- - } - } else if input.After != nil { - if int(invoices[0].ID) == input.After.After { - hasPrevPage = true - invoices = invoices[1:] - count-- - } - } - if count == opts.Limit { - // No previous page - invoices = invoices[:count-1] - count-- - } - - if count > numElements { - hasNextPage = true - invoices = invoices[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = int(invoices[0].ID) - c.Before = int(invoices[count-1].ID) - } else { - c.After = int(invoices[count-1].ID) - c.Before = int(invoices[0].ID) - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } // Payment is a wrap for invoice aimed // to expose only the required fields for payment history var payments []*model.Payment @@ -5278,33 +5211,18 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp } - numElements := model.PaginationDefault - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.Expr("ol.id <= ?", input.After.After), - } - numElements = input.After.Limit - } else if input.Before != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.Expr("ol.id >= ?", input.Before.Before), - } - numElements = input.Before.Limit - } - - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - linkOpts.Limit = numElements + 2 - orgLinks, err := models.GetOrgLinks(ctx, linkOpts) + orgLinks, pageInfo, err := QueryModel( + ctx, + linkOpts, + "ol.id", + input.Limit, + input.Before, + input.After, + models.GetOrgLinks, + func(link *models.OrgLink) int { + return link.ID + }, + ) if err != nil { return nil, err } @@ -5318,55 +5236,11 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp } } - c := model.Cursor{Limit: numElements} - count := len(orgLinks) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if orgLinks[0].ID == input.Before.Before { - hasPrevPage = true - orgLinks = orgLinks[1:] - count-- - } - } else if input.After != nil { - if orgLinks[0].ID == input.After.After { - hasPrevPage = true - orgLinks = orgLinks[1:] - count-- - } - } - if count == linkOpts.Limit { - // No previous page - orgLinks = orgLinks[:count-1] - count-- - } - - if count > numElements { - hasNextPage = true - orgLinks = orgLinks[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = orgLinks[0].ID - c.Before = orgLinks[count-1].ID - } else { - c.After = orgLinks[count-1].ID - c.Before = orgLinks[0].ID - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } - return &model.OrgLinkCursor{Result: orgLinks, PageInfo: pageInfo, RestrictedCount: &resCount}, nil + return &model.OrgLinkCursor{ + Result: orgLinks, + PageInfo: pageInfo, + RestrictedCount: &resCount, + }, nil } // GetOrgMembers is the resolver for the getOrgMembers field. @@ -5579,7 +5453,7 @@ func (r *queryResolver) GetLinkShorts(ctx context.Context, input *model.GetLinkS Filter: sq.And{ sq.Expr("l.org_id = ?", org.ID), }, - OrderBy: "id DESC", + OrderBy: "l.id DESC", } if input.After != nil && input.Before != nil { validator.Error("%s", lt.Translate("You can not send both after and before cursors")). @@ -5599,86 +5473,22 @@ func (r *queryResolver) GetLinkShorts(ctx context.Context, input *model.GetLinkS } } - numElements := model.PaginationDefault - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.Expr("l.id <= ?", input.After.After), - } - numElements = input.After.Limit - } else if input.Before != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.Expr("l.id >= ?", input.Before.Before), - } - linkOpts.OrderBy = "l.id ASC" - numElements = input.Before.Limit - } - - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - linkOpts.Limit = numElements + 2 - linkShorts, err := models.GetLinkShorts(ctx, linkOpts) + linkShorts, pageInfo, err := QueryModel( + ctx, + linkOpts, + "l.id", + input.Limit, + input.Before, + input.After, + models.GetLinkShorts, + func(x *models.LinkShort) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(linkShorts) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if linkShorts[0].ID == input.Before.Before { - hasPrevPage = true - linkShorts = linkShorts[1:] - count-- - } - } else if input.After != nil { - if linkShorts[0].ID == input.After.After { - hasPrevPage = true - linkShorts = linkShorts[1:] - count-- - } - } - if count == linkOpts.Limit { - // No previous page - linkShorts = linkShorts[:count-1] - count-- - } - - if count > numElements { - hasNextPage = true - linkShorts = linkShorts[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = linkShorts[0].ID - c.Before = linkShorts[count-1].ID - } else { - c.After = linkShorts[count-1].ID - c.Before = linkShorts[0].ID - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.LinkShortCursor{Result: linkShorts, PageInfo: pageInfo}, nil } @@ -5786,85 +5596,22 @@ func (r *queryResolver) GetListings(ctx context.Context, input *model.GetListing } } - numElements := 10 - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - listingOpts.Filter = sq.And{ - listingOpts.Filter, - sq.Expr("l.id <= ?", input.After.After), - } - numElements = input.After.Limit - } else if input.Before != nil { - listingOpts.Filter = sq.And{ - listingOpts.Filter, - sq.Expr("l.id >= ?", input.Before.Before), - } - listingOpts.OrderBy = "l.id ASC" - numElements = input.Before.Limit - } - - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - listingOpts.Limit = numElements + 2 - listings, err := models.GetListings(ctx, listingOpts) + listings, pageInfo, err := QueryModel( + ctx, + listingOpts, + "l.id", + input.Limit, + input.Before, + input.After, + models.GetListings, + func(x *models.Listing) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(listings) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if listings[0].ID == input.Before.Before { - hasPrevPage = true - listings = listings[1:] - count-- - } - } else if input.After != nil { - if listings[0].ID == input.After.After { - hasPrevPage = true - listings = listings[1:] - count-- - } - } - if count == listingOpts.Limit { - // No previous page - listings = listings[:count-1] - count-- - } - if count > numElements { - hasNextPage = true - listings = listings[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = listings[0].ID - c.Before = listings[count-1].ID - } else { - c.After = listings[count-1].ID - c.Before = listings[0].ID - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.ListingCursor{Result: listings, PageInfo: pageInfo}, nil } @@ -5933,85 +5680,22 @@ func (r *queryResolver) GetListing(ctx context.Context, input *model.GetListingD OrderBy: "ll.link_order ASC", } - numElements := 10 - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.LtOrEq{"ll.id": input.After.After}, - } - numElements = input.After.Limit - } else if input.Before != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.GtOrEq{"ll.id": input.Before.Before}, - } - linkOpts.OrderBy = "ll.id ASC" - numElements = input.Before.Limit - } - - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - linkOpts.Limit = numElements + 2 - listingLinks, err := models.GetListingLinks(ctx, linkOpts) + listingLinks, pageInfo, err := QueryModel( + ctx, + linkOpts, + "ll.link_order", + input.Limit, + input.Before, + input.After, + models.GetListingLinks, + func(x *models.ListingLink) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(listingLinks) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if listingLinks[0].ID == input.Before.Before { - hasPrevPage = true - listingLinks = listingLinks[1:] - count-- - } - } else if input.After != nil { - if listingLinks[0].ID == input.After.After { - hasPrevPage = true - listingLinks = listingLinks[1:] - count-- - } - } - if count == linkOpts.Limit { - // No previous page - listingLinks = listingLinks[:count-1] - count-- - } - if count > numElements { - hasNextPage = true - listingLinks = listingLinks[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = listingLinks[0].ID - c.Before = listingLinks[count-1].ID - } else { - c.After = listingLinks[count-1].ID - c.Before = listingLinks[0].ID - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.ListingLinkCursor{Result: listing, Links: listingLinks, PageInfo: pageInfo}, nil } @@ -6530,6 +6214,12 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput) ctx = timezone.Context(ctx, links.GetUserTZ(user)) validator := valid.New(ctx) + if input.After != nil && input.Before != nil { + validator.Error("%s", lt.Translate("You can not send both after and before cursors")). + WithCode(valid.ErrValidationGlobalCode) + return nil, nil + } + linkOpts := &database.FilterOptions{ Filter: sq.Or{ // An user follows another user @@ -6569,92 +6259,22 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput) } - if input.After != nil && input.Before != nil { - validator.Error("%s", lt.Translate("You can not send both after and before cursors")). - WithCode(valid.ErrValidationGlobalCode) - return nil, nil - } - - numElements := model.PaginationDefault - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.Expr("ol.id <= ?", input.After.After), - } - numElements = input.After.Limit - } else if input.Before != nil { - linkOpts.Filter = sq.And{ - linkOpts.Filter, - sq.Expr("ol.id >= ?", input.Before.Before), - } - linkOpts.OrderBy = "ol.id ASC" - numElements = input.Before.Limit - } - - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - linkOpts.Limit = numElements + 2 - orgLinks, err := models.GetOrgLinks(ctx, linkOpts) + orgLinks, pageInfo, err := QueryModel( + ctx, + linkOpts, + "ol.id", + input.Limit, + input.Before, + input.After, + models.GetOrgLinks, + func(x *models.OrgLink) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(orgLinks) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if orgLinks[0].ID == input.Before.Before { - hasPrevPage = true - orgLinks = orgLinks[1:] - count-- - } - } else if input.After != nil { - if orgLinks[0].ID == input.After.After { - hasPrevPage = true - orgLinks = orgLinks[1:] - count-- - } - } - if count == linkOpts.Limit { - // No previous page - orgLinks = orgLinks[:count-1] - count-- - } - - if count > numElements { - hasNextPage = true - orgLinks = orgLinks[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = orgLinks[0].ID - c.Before = orgLinks[count-1].ID - } else { - c.After = orgLinks[count-1].ID - c.Before = orgLinks[0].ID - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.OrgLinkCursor{Result: orgLinks, PageInfo: pageInfo}, nil } @@ -6814,86 +6434,22 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI } } - numElements := model.PaginationDefault - var hasPrevPage, hasNextPage bool - if input.After != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.Expr("al.id <= ?", input.After.After), - } - numElements = input.After.Limit - } else if input.Before != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.Expr("al.id >= ?", input.Before.Before), - } - opts.OrderBy = "al.id ASC" - numElements = input.Before.Limit - } - - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - opts.Limit = numElements + 2 - - alogs, err := auditlog.GetAuditLogs(ctx, opts) + alogs, pageInfo, err := QueryModel( + ctx, + opts, + "al.id", + input.Limit, + input.Before, + input.After, + auditlog.GetAuditLogs, + func(x *auditlog.AuditLog) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(alogs) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if alogs[0].ID == input.Before.Before { - hasPrevPage = true - alogs = alogs[1:] - count-- - } - } else if input.After != nil { - if alogs[0].ID == input.After.After { - hasPrevPage = true - alogs = alogs[1:] - count-- - } - } - if count == opts.Limit { - // No previous page - alogs = alogs[:count-1] - count-- - } - - if count > numElements { - hasNextPage = true - alogs = alogs[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = alogs[0].ID - c.Before = alogs[count-1].ID - } else { - c.After = alogs[count-1].ID - c.Before = alogs[0].ID - } - } else { - hasPrevPage = false - } - } - - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.AuditLogCursor{Result: alogs, PageInfo: pageInfo}, nil } @@ -6941,84 +6497,22 @@ func (r *queryResolver) GetUsers(ctx context.Context, input *model.GetUserInput) } - numElements := model.PaginationDefault - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.LtOrEq{"u.id": input.After.After}, - } - numElements = input.After.Limit - } else if input.Before != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.GtOrEq{"u.id": input.Before.Before}, - } - opts.OrderBy = "u.id ASC" - numElements = input.Before.Limit - } - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - opts.Limit = numElements + 2 - users, err := models.GetUsers(ctx, opts) + users, pageInfo, err := QueryModel( + ctx, + opts, + "u.id", + input.Limit, + input.Before, + input.After, + models.GetUsers, + func(x *models.User) int { + return int(x.ID) + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(users) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if int(users[0].ID) == input.Before.Before { - hasPrevPage = true - users = users[1:] - count-- - } - } else if input.After != nil { - if int(users[0].ID) == input.After.After { - hasPrevPage = true - users = users[1:] - count-- - } - } - if count == opts.Limit { - // No previous page - users = users[:count-1] - count-- - } - if count > numElements { - hasNextPage = true - users = users[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = int(users[0].ID) - c.Before = int(users[count-1].ID) - } else { - c.After = int(users[count-1].ID) - c.Before = int(users[0].ID) - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.UserCursor{Result: users, PageInfo: pageInfo}, nil } @@ -7094,84 +6588,22 @@ func (r *queryResolver) GetAdminOrganizations(ctx context.Context, input *model. } } - numElements := model.PaginationDefault - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.LtOrEq{"o.id": input.After.After}, - } - numElements = input.After.Limit - } else if input.Before != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.GtOrEq{"o.id": input.Before.Before}, - } - opts.OrderBy = "o.id ASC" - numElements = input.Before.Limit - } - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - opts.Limit = numElements + 2 - orgs, err := models.GetOrganizations(ctx, opts) + orgs, pageInfo, err := QueryModel( + ctx, + opts, + "o.id", + input.Limit, + input.Before, + input.After, + models.GetOrganizations, + func(x *models.Organization) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(orgs) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if int(orgs[0].ID) == input.Before.Before { - hasPrevPage = true - orgs = orgs[1:] - count-- - } - } else if input.After != nil { - if int(orgs[0].ID) == input.After.After { - hasPrevPage = true - orgs = orgs[1:] - count-- - } - } - if count == opts.Limit { - // No previous page - orgs = orgs[:count-1] - count-- - } - if count > numElements { - hasNextPage = true - orgs = orgs[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = int(orgs[0].ID) - c.Before = int(orgs[count-1].ID) - } else { - c.After = int(orgs[count-1].ID) - c.Before = int(orgs[0].ID) - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.OrganizationCursor{Result: orgs, PageInfo: pageInfo}, nil } @@ -7353,6 +6785,12 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm return nil, nil } + if input.After != nil && input.Before != nil { + validator.Error("%s", lt.Translate("You can not send both after and before cursors")). + WithCode(valid.ErrValidationGlobalCode) + return nil, nil + } + opts := &database.FilterOptions{ Filter: sq.And{}, OrderBy: "d.id DESC", @@ -7371,12 +6809,6 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm } } - if input.After != nil && input.Before != nil { - validator.Error("%s", lt.Translate("You can not send both after and before cursors")). - WithCode(valid.ErrValidationGlobalCode) - return nil, nil - } - if input.FilterLevel != nil { opts.Filter = sq.And{ opts.Filter, @@ -7406,84 +6838,22 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm } - numElements := model.PaginationDefault - var hasPrevPage bool - var hasNextPage bool - if input.After != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.LtOrEq{"d.id": input.After.After}, - } - numElements = input.After.Limit - } else if input.Before != nil { - opts.Filter = sq.And{ - opts.Filter, - sq.GtOrEq{"d.id": input.Before.Before}, - } - opts.OrderBy = "d.id ASC" - numElements = input.Before.Limit - } - // If limit specifically set, it overrides any cursor limit - if input.Limit != nil && *input.Limit > 0 { - numElements = *input.Limit - } - if numElements > model.PaginationMax { - numElements = model.PaginationMax - } - - opts.Limit = numElements + 2 - domains, err := models.GetDomains(ctx, opts) + domains, pageInfo, err := QueryModel( + ctx, + opts, + "d.id", + input.Limit, + input.Before, + input.After, + models.GetDomains, + func(x *models.Domain) int { + return x.ID + }, + ) if err != nil { return nil, err } - c := model.Cursor{Limit: numElements} - count := len(domains) - if count > 0 { - // Checking for previous page - if input.Before != nil { - if int(domains[0].ID) == input.Before.Before { - hasPrevPage = true - domains = domains[1:] - count-- - } - } else if input.After != nil { - if int(domains[0].ID) == input.After.After { - hasPrevPage = true - domains = domains[1:] - count-- - } - } - if count == opts.Limit { - // No previous page - domains = domains[:count-1] - count-- - } - if count > numElements { - hasNextPage = true - domains = domains[:count-1] - count-- - } - if count > 0 { - if input.Before != nil { - tmp := hasPrevPage - hasPrevPage = hasNextPage - hasNextPage = tmp - c.After = int(domains[0].ID) - c.Before = int(domains[count-1].ID) - } else { - c.After = int(domains[count-1].ID) - c.Before = int(domains[0].ID) - } - } else { - hasPrevPage = false - } - } - pageInfo := &model.PageInfo{ - Cursor: c, - HasNextPage: hasNextPage, - HasPrevPage: hasPrevPage, - } return &model.DomainCursor{Result: domains, PageInfo: pageInfo}, nil } diff --git a/billing/routes.go b/billing/routes.go index 5fd4e83..7849f1e 100644 --- a/billing/routes.go +++ b/billing/routes.go @@ -7,6 +7,7 @@ import ( "links/internal/localizer" "links/models" "net/http" + "slices" "strconv" "git.sr.ht/~emersion/gqlclient" @@ -273,6 +274,10 @@ func (s *Service) SubscriptionHistory(c echo.Context) error { return err } + if c.QueryParam("prev") != "" { + slices.Reverse(result.Payments.Result) + } + gmap := gobwebs.Map{ "pd": pd, "invoices": result.Payments.Result, diff --git a/core/routes.go b/core/routes.go index d6bdf55..94a8a23 100644 --- a/core/routes.go +++ b/core/routes.go @@ -13,6 +13,7 @@ import ( "links/slack" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -1835,6 +1836,9 @@ func (s *Service) UserFeed(c echo.Context) error { pd.Data["clear"] = lt.Translate("Clear") pd.Data["followings"] = lt.Translate("Followings") orgLinks := result.OrgLinks.Result + if c.QueryParam("prev") != "" { + slices.Reverse(orgLinks) + } if links.IsRSS(c.Path()) { domain := fmt.Sprintf("%s://%s", gctx.Server.Config.Scheme, gctx.Server.Config.Domain) @@ -2213,6 +2217,10 @@ func (s *Service) OrgLinksList(c echo.Context) error { return links.ServerRSSFeed(c.Response(), rss) } + if c.QueryParam("prev") != "" { + slices.Reverse(orgLinks) + } + seoData := links.GetSEOData(c) url := links.GetLinksDomainURL(c) if isOrgLink { diff --git a/list/routes.go b/list/routes.go index 1271fbf..2f82358 100644 --- a/list/routes.go +++ b/list/routes.go @@ -11,6 +11,7 @@ import ( "links/models" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -569,6 +570,10 @@ func (s *Service) ListingLinksManage(c echo.Context) error { return err } + if c.QueryParam("prev") != "" { + slices.Reverse(result.Listing.Links) + } + lt := localizer.GetSessionLocalizer(c) pd := localizer.NewPageData(lt.Translate("%s Links", result.Listing.Result.Title)) pd.Data["edit"] = lt.Translate("Edit") @@ -1106,6 +1111,9 @@ func (s *Service) ListingList(c echo.Context) error { } return err } + if c.QueryParam("prev") != "" { + slices.Reverse(result.Listings.Result) + } gmap := gobwebs.Map{ "pd": pd, "org": org, diff --git a/mattermost/routes.go b/mattermost/routes.go index e9094d0..19ebef1 100644 --- a/mattermost/routes.go +++ b/mattermost/routes.go @@ -135,12 +135,6 @@ func (s *Service) Connect(c echo.Context) error { return s.Render(c, http.StatusOK, "connect_mattermost.html", gmap) } - opts = &database.FilterOptions{ - Filter: sq.And{ - sq.Eq{"o.owner_id": user.ID}, - sq.Eq{"o.is_active": true}, - }, - } gmap := gobwebs.Map{ "pd": pd, "org": org, diff --git a/short/routes.go b/short/routes.go index 96d1106..ebbac75 100644 --- a/short/routes.go +++ b/short/routes.go @@ -11,6 +11,7 @@ import ( "links/models" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -167,6 +168,9 @@ func (s *Service) LinkShortList(c echo.Context) error { pd.Data["clear"] = lt.Translate("Clear") linkShorts := result.LinkShorts.Result + if c.QueryParam("prev") != "" { + slices.Reverse(linkShorts) + } gmap := gobwebs.Map{ "pd": pd, "links": linkShorts, -- 2.47.2