diff --git a/backend/internal/handler/redeem_handler.go b/backend/internal/handler/redeem_handler.go
index 1b63f4183f..5d919cdc4e 100644
--- a/backend/internal/handler/redeem_handler.go
+++ b/backend/internal/handler/redeem_handler.go
@@ -1,7 +1,10 @@
package handler
import (
+ "strconv"
+
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -68,6 +71,39 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) {
return
}
+ // Keep the legacy array response for clients that do not request pagination.
+ if c.Request.URL.Query().Has("page") || c.Request.URL.Query().Has("page_size") {
+ page, pageSize := 1, 20
+ for name, target := range map[string]*int{"page": &page, "page_size": &pageSize} {
+ if raw, exists := c.GetQuery(name); exists {
+ value, err := strconv.Atoi(raw)
+ if err != nil || value < 1 {
+ response.BadRequest(c, "Invalid "+name)
+ return
+ }
+ *target = value
+ }
+ }
+ if pageSize > 100 {
+ pageSize = 100
+ }
+ if page-1 > int(^uint(0)>>1)/pageSize {
+ response.BadRequest(c, "Invalid page")
+ return
+ }
+ codes, result, err := h.redeemService.GetUserHistoryPaginated(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: page, PageSize: pageSize})
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ out := make([]dto.RedeemCode, 0, len(codes))
+ for i := range codes {
+ out = append(out, *dto.RedeemCodeFromService(&codes[i]))
+ }
+ response.Paginated(c, out, result.Total, page, pageSize)
+ return
+ }
+
// Default limit is 25
limit := 25
diff --git a/backend/internal/handler/redeem_handler_test.go b/backend/internal/handler/redeem_handler_test.go
new file mode 100644
index 0000000000..af84d690fc
--- /dev/null
+++ b/backend/internal/handler/redeem_handler_test.go
@@ -0,0 +1,89 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type historyRepo struct {
+ service.RedeemCodeRepository
+ userID int64
+ params pagination.PaginationParams
+ legacyLimit int
+}
+
+func (r *historyRepo) ListByUser(_ context.Context, userID int64, limit int) ([]service.RedeemCode, error) {
+ r.userID, r.legacyLimit = userID, limit
+ return []service.RedeemCode{}, nil
+}
+func (r *historyRepo) ListByUserPaginated(_ context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
+ r.userID, r.params = userID, params
+ return []service.RedeemCode{}, &pagination.PaginationResult{Total: 101}, nil
+}
+func TestRedeemHistory(t *testing.T) {
+ for _, tt := range []struct {
+ name, query string
+ userID int64
+ status, page, size int
+ }{
+ {"legacy", "", 7, 200, 0, 0},
+ {"default", "?page=1", 7, 200, 1, 20},
+ {"size only", "?page_size=50", 7, 200, 1, 50},
+ {"second user", "?page=2&page_size=100&user_id=7", 8, 200, 2, 100},
+ {"cap", "?page_size=101", 7, 200, 1, 100},
+ {"beyond last", "?page=100&page_size=20", 7, 200, 100, 20},
+ {"zero", "?page=0", 7, 400, 0, 0},
+ {"negative", "?page_size=-1", 7, 400, 0, 0},
+ {"invalid", "?page=abc", 7, 400, 0, 0},
+ {"overflow", "?page=9223372036854775807", 7, 400, 0, 0},
+ {"unauthenticated", "?page=1", 0, 401, 0, 0},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &historyRepo{}
+ h := NewRedeemHandler(service.NewRedeemService(repo, nil, nil, nil, nil, nil, nil, nil))
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest("GET", "/api/v1/redeem/history"+tt.query, nil)
+ if tt.userID != 0 {
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: tt.userID})
+ }
+ h.GetHistory(c)
+ require.Equal(t, tt.status, w.Code)
+ if tt.status != 200 {
+ require.Zero(t, repo.userID)
+ return
+ }
+ require.Equal(t, tt.userID, repo.userID)
+ var body struct {
+ Data json.RawMessage `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ if tt.query == "" {
+ require.Equal(t, 25, repo.legacyLimit)
+ require.JSONEq(t, "[]", string(body.Data))
+ return
+ }
+ require.Equal(t, tt.page, repo.params.Page)
+ require.Equal(t, tt.size, repo.params.PageSize)
+ var data struct {
+ Items []service.RedeemCode `json:"items"`
+ Total int `json:"total"`
+ Page int `json:"page"`
+ Size int `json:"page_size"`
+ }
+ require.NoError(t, json.Unmarshal(body.Data, &data))
+ require.NotNil(t, data.Items)
+ require.Equal(t, 101, data.Total)
+ require.Equal(t, tt.page, data.Page)
+ require.Equal(t, tt.size, data.Size)
+ })
+ }
+}
diff --git a/backend/internal/repository/redeem_code_repo.go b/backend/internal/repository/redeem_code_repo.go
index 2bdb34b42f..ef2841705d 100644
--- a/backend/internal/repository/redeem_code_repo.go
+++ b/backend/internal/repository/redeem_code_repo.go
@@ -347,7 +347,7 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
codes, err := r.client.RedeemCode.Query().
Where(redeemcode.UsedByEQ(userID)).
WithGroup().
- Order(dbent.Desc(redeemcode.FieldUsedAt)).
+ Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
Limit(limit).
All(ctx)
if err != nil {
@@ -377,7 +377,7 @@ func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID i
WithGroup().
Offset(params.Offset()).
Limit(params.Limit()).
- Order(dbent.Desc(redeemcode.FieldUsedAt)).
+ Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
All(ctx)
if err != nil {
return nil, nil, err
diff --git a/backend/internal/repository/redeem_code_repo_sort_integration_test.go b/backend/internal/repository/redeem_code_repo_sort_integration_test.go
index 30d32f4cf9..3b5b73e5d7 100644
--- a/backend/internal/repository/redeem_code_repo_sort_integration_test.go
+++ b/backend/internal/repository/redeem_code_repo_sort_integration_test.go
@@ -3,8 +3,10 @@
package repository
import (
+ "fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
+ "time"
)
func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
@@ -22,3 +24,50 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
s.Require().Equal("VALUE-10", codes[0].Code)
s.Require().Equal("VALUE-20", codes[1].Code)
}
+
+func (s *RedeemCodeRepoSuite) TestHistoryPaginationIsolationAndOrdering() {
+ user := s.createUser("history@example.com")
+ other := s.createUser("other-history@example.com")
+ now := time.Now().UTC().Truncate(time.Second)
+ var ids []int64
+ types := []string{service.RedeemTypeBalance, service.RedeemTypeConcurrency, service.RedeemTypeSubscription, "admin_balance", "admin_concurrency"}
+ for i := 0; i < 105; i++ {
+ usedAt := now
+ if i == 0 {
+ usedAt = now.Add(time.Hour)
+ }
+ code, err := s.client.RedeemCode.Create().SetCode(fmt.Sprintf("HISTORY-%d", i)).SetType(types[i%len(types)]).SetStatus(service.StatusUsed).SetUsedBy(user.ID).SetUsedAt(usedAt).Save(s.ctx)
+ s.Require().NoError(err)
+ ids = append(ids, code.ID)
+ }
+ _, err := s.client.RedeemCode.Create().SetCode("OTHER-HISTORY").SetType(service.RedeemTypeSubscription).SetStatus(service.StatusUsed).SetUsedBy(other.ID).SetUsedAt(now.Add(2 * time.Hour)).Save(s.ctx)
+ s.Require().NoError(err)
+ expected := []int64{ids[0]}
+ for i := len(ids) - 1; i > 0; i-- {
+ expected = append(expected, ids[i])
+ }
+ for _, size := range []int{20, 50, 100} {
+ var got []int64
+ for page := 1; page <= (105+size-1)/size+1; page++ {
+ codes, result, err := s.repo.ListByUserPaginated(s.ctx, user.ID, pagination.PaginationParams{Page: page, PageSize: size}, "")
+ s.Require().NoError(err)
+ s.Equal(int64(105), result.Total)
+ s.LessOrEqual(len(codes), size)
+ for _, code := range codes {
+ s.Equal(user.ID, *code.UsedBy)
+ got = append(got, code.ID)
+ }
+ }
+ s.Equal(expected, got)
+ }
+ codes, result, err := s.repo.ListByUserPaginated(s.ctx, other.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
+ s.Require().NoError(err)
+ s.Equal(int64(1), result.Total)
+ s.Require().Len(codes, 1)
+ s.Equal("OTHER-HISTORY", codes[0].Code)
+ empty := s.createUser("empty-history@example.com")
+ codes, result, err = s.repo.ListByUserPaginated(s.ctx, empty.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
+ s.Require().NoError(err)
+ s.Empty(codes)
+ s.Zero(result.Total)
+}
diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go
index 5e7cdee685..7b42b04d70 100644
--- a/backend/internal/service/redeem_service.go
+++ b/backend/internal/service/redeem_service.go
@@ -742,3 +742,8 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID,
return nil
}
+
+// GetUserHistoryPaginated returns all redemption types for the authenticated user.
+func (s *RedeemService) GetUserHistoryPaginated(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) {
+ return s.redeemRepo.ListByUserPaginated(ctx, userID, params, "")
+}
diff --git a/frontend/src/api/__tests__/redeem.spec.ts b/frontend/src/api/__tests__/redeem.spec.ts
new file mode 100644
index 0000000000..8c4284e9f5
--- /dev/null
+++ b/frontend/src/api/__tests__/redeem.spec.ts
@@ -0,0 +1,20 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getHistory } from '../redeem'
+
+const { get } = vi.hoisted(() => ({ get: vi.fn() }))
+vi.mock('../client', () => ({ apiClient: { get } }))
+
+describe('redemption history pagination', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it.each([[undefined, undefined, 1, 20], [3, 50, 3, 50], [2, 100, 2, 100]])(
+ 'sends page %s and size %s to the server', async (page, size, expectedPage, expectedSize) => {
+ const response = { items: [], total: 105, page: expectedPage, page_size: expectedSize, pages: 6 }
+ get.mockResolvedValue({ data: response })
+ expect(await getHistory(page, size)).toEqual(response)
+ expect(get).toHaveBeenCalledWith('/redeem/history', {
+ params: { page: expectedPage, page_size: expectedSize }
+ })
+ }
+ )
+})
diff --git a/frontend/src/api/redeem.ts b/frontend/src/api/redeem.ts
index 22abf4d80c..4a2eb3a544 100644
--- a/frontend/src/api/redeem.ts
+++ b/frontend/src/api/redeem.ts
@@ -4,7 +4,7 @@
*/
import { apiClient } from './client'
-import type { RedeemCodeRequest } from '@/types'
+import type { PaginatedResponse, RedeemCodeRequest } from '@/types'
export interface RedeemHistoryItem {
id: number
@@ -52,10 +52,12 @@ export async function redeem(code: string): Promise<{
/**
* Get user's redemption history
- * @returns List of redeemed codes
+ * @returns The requested page of redeemed codes and the total count
*/
-export async function getHistory(): Promise