From 12603a7765cfbccfa71ec9d9a9d5d00f51dae9a9 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 4 Jul 2026 16:14:54 +0800 Subject: [PATCH] fix(redemption): add status filtering and cleanup action --- controller/redemption.go | 3 +- model/redemption.go | 36 ++++-- model/redemption_test.go | 100 ++++++++++++++++ .../src/features/redemption-codes/api.ts | 11 +- .../components/data-table-bulk-actions.tsx | 108 +++--------------- .../redemptions-primary-buttons.tsx | 77 +++++++++++-- .../components/redemptions-table.tsx | 51 +++++++-- .../features/redemption-codes/constants.ts | 11 +- .../src/features/redemption-codes/types.ts | 1 + .../_authenticated/redemption-codes/index.tsx | 4 +- 10 files changed, 272 insertions(+), 130 deletions(-) create mode 100644 model/redemption_test.go diff --git a/controller/redemption.go b/controller/redemption.go index 7f877c29..838746e7 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -29,8 +29,9 @@ func GetAllRedemptions(c *gin.Context) { func SearchRedemptions(c *gin.Context) { keyword := c.Query("keyword") + status := c.Query("status") pageInfo := common.GetPageQuery(c) - redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + redemptions, total, err := model.SearchRedemptions(keyword, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return diff --git a/model/redemption.go b/model/redemption.go index f64361fc..f29f867c 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -60,7 +60,7 @@ func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total return redemptions, total, nil } -func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) { +func SearchRedemptions(keyword string, status string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) { tx := DB.Begin() if tx.Error != nil { return nil, 0, tx.Error @@ -71,14 +71,36 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re } }() - // Build query based on keyword type query := tx.Model(&Redemption{}) - // Only try to convert to ID if the string represents a valid integer - if id, err := strconv.Atoi(keyword); err == nil { - query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") - } else { - query = query.Where("name LIKE ?", keyword+"%") + if keyword != "" { + if id, err := strconv.Atoi(keyword); err == nil { + query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + } else { + query = query.Where("name LIKE ?", keyword+"%") + } + } + + if status != "" { + now := common.GetTimestamp() + switch status { + case "expired": + query = query.Where( + "status = ? AND expired_time != 0 AND expired_time < ?", + common.RedemptionCodeStatusEnabled, + now, + ) + case strconv.Itoa(common.RedemptionCodeStatusEnabled): + query = query.Where( + "status = ? AND (expired_time = 0 OR expired_time >= ?)", + common.RedemptionCodeStatusEnabled, + now, + ) + case strconv.Itoa(common.RedemptionCodeStatusDisabled): + query = query.Where("status = ?", common.RedemptionCodeStatusDisabled) + case strconv.Itoa(common.RedemptionCodeStatusUsed): + query = query.Where("status = ?", common.RedemptionCodeStatusUsed) + } } // Get total count diff --git a/model/redemption_test.go b/model/redemption_test.go new file mode 100644 index 00000000..c9da024b --- /dev/null +++ b/model/redemption_test.go @@ -0,0 +1,100 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestSearchRedemptionsFiltersAndPaginates(t *testing.T) { + require.NoError(t, DB.AutoMigrate(&Redemption{})) + require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error) + t.Cleanup(func() { + require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error) + }) + + now := common.GetTimestamp() + redemptions := []Redemption{ + {Id: 1, Name: "alpha-active", Key: "00000000000000000000000000000001", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: 0}, + {Id: 2, Name: "alpha-future", Key: "00000000000000000000000000000002", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: now + 3600}, + {Id: 3, Name: "alpha-expired", Key: "00000000000000000000000000000003", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: now - 10}, + {Id: 4, Name: "beta-disabled", Key: "00000000000000000000000000000004", Status: common.RedemptionCodeStatusDisabled, ExpiredTime: 0}, + {Id: 5, Name: "beta-used", Key: "00000000000000000000000000000005", Status: common.RedemptionCodeStatusUsed, ExpiredTime: 0}, + } + require.NoError(t, DB.Create(&redemptions).Error) + + tests := []struct { + name string + keyword string + status string + startIdx int + num int + wantTotal int64 + wantIds []int + }{ + { + name: "no filters returns all rows", + num: 10, + wantTotal: 5, + wantIds: []int{5, 4, 3, 2, 1}, + }, + { + name: "keyword filters by name prefix", + keyword: "alpha", + num: 10, + wantTotal: 3, + wantIds: []int{3, 2, 1}, + }, + { + name: "enabled status excludes expired rows", + status: "1", + num: 10, + wantTotal: 2, + wantIds: []int{2, 1}, + }, + { + name: "expired status returns enabled expired rows", + status: "expired", + num: 10, + wantTotal: 1, + wantIds: []int{3}, + }, + { + name: "disabled status", + status: "2", + num: 10, + wantTotal: 1, + wantIds: []int{4}, + }, + { + name: "used status", + status: "3", + num: 10, + wantTotal: 1, + wantIds: []int{5}, + }, + { + name: "pagination keeps unpaged total", + startIdx: 1, + num: 2, + wantTotal: 5, + wantIds: []int{4, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, total, err := SearchRedemptions(tt.keyword, tt.status, tt.startIdx, tt.num) + require.NoError(t, err) + assert.Equal(t, tt.wantTotal, total) + gotIds := make([]int, 0, len(rows)) + for _, row := range rows { + gotIds = append(gotIds, row.Id) + } + assert.Equal(t, tt.wantIds, gotIds) + }) + } +} diff --git a/web/default/src/features/redemption-codes/api.ts b/web/default/src/features/redemption-codes/api.ts index 1bfe3357..947b66f8 100644 --- a/web/default/src/features/redemption-codes/api.ts +++ b/web/default/src/features/redemption-codes/api.ts @@ -44,10 +44,13 @@ export async function getRedemptions( export async function searchRedemptions( params: SearchRedemptionsParams ): Promise { - const { keyword = '', p = 1, page_size = 10 } = params - const res = await api.get( - `/api/redemption/search?keyword=${keyword}&p=${p}&page_size=${page_size}` - ) + const { keyword = '', status = '', p = 1, page_size = 10 } = params + const queryParams = new URLSearchParams() + queryParams.set('keyword', keyword) + if (status) queryParams.set('status', status) + queryParams.set('p', String(p)) + queryParams.set('page_size', String(page_size)) + const res = await api.get(`/api/redemption/search?${queryParams.toString()}`) return res.data } diff --git a/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx b/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx index 66e69aff..ef65b10a 100644 --- a/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx +++ b/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx @@ -16,25 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { type Table } from '@tanstack/react-table' -import { Trash2 } from 'lucide-react' -import { useState, useMemo } from 'react' +import type { Table } from '@tanstack/react-table' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' -import { ConfirmDialog } from '@/components/confirm-dialog' import { CopyButton } from '@/components/copy-button' import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' -import { Button } from '@/components/ui/button' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { deleteInvalidRedemptions } from '../api' -import { type Redemption } from '../types' -import { useRedemptions } from './redemptions-provider' +import type { Redemption } from '../types' type DataTableBulkActionsProps = { table: Table @@ -44,11 +33,7 @@ export function DataTableBulkActions({ table, }: DataTableBulkActionsProps) { const { t } = useTranslation() - const { triggerRefresh } = useRedemptions() - const [showDeleteInvalidConfirm, setShowDeleteInvalidConfirm] = - useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedRows = table.getSelectedRowModel().rows const contentToCopy = useMemo(() => { const selectedCodes = selectedRows.map((row) => { @@ -58,82 +43,17 @@ export function DataTableBulkActions({ return selectedCodes.join('\n') }, [selectedRows]) - const handleDeleteInvalid = async () => { - setIsDeleting(true) - try { - const result = await deleteInvalidRedemptions() - - if (result.success) { - const count = result.data || 0 - toast.success( - t('Successfully deleted {{count}} invalid redemption codes', { - count, - }) - ) - table.resetRowSelection() - triggerRefresh() - setShowDeleteInvalidConfirm(false) - } - } finally { - setIsDeleting(false) - } - } - return ( - <> - - - - - setShowDeleteInvalidConfirm(true)} - className='size-8' - aria-label={t('Delete invalid redemption codes')} - title={t('Delete invalid redemption codes')} - /> - } - > - - {t('Delete invalid codes')} - - -

{t('Delete invalid codes (used/disabled/expired)')}

-
-
-
- - - {t('This will delete all')} {t('used')},{' '} - {t('disabled')} - {t(', and')} {t('expired')}{' '} - {t('redemption codes.')} -
- {t('This action cannot be undone.')} - - } - confirmText={t('Delete Invalid')} + + - + ) } diff --git a/web/default/src/features/redemption-codes/components/redemptions-primary-buttons.tsx b/web/default/src/features/redemption-codes/components/redemptions-primary-buttons.tsx index 39fa8aae..62bd3058 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-primary-buttons.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-primary-buttons.tsx @@ -16,22 +16,83 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Plus } from 'lucide-react' +import { Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { ConfirmDialog } from '@/components/confirm-dialog' import { Button } from '@/components/ui/button' +import { deleteInvalidRedemptions } from '../api' +import { ERROR_MESSAGES } from '../constants' import { useRedemptions } from './redemptions-provider' export function RedemptionsPrimaryButtons() { const { t } = useTranslation() - const { setOpen } = useRedemptions() + const { setOpen, triggerRefresh } = useRedemptions() + const [showDeleteInvalidConfirm, setShowDeleteInvalidConfirm] = + useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const handleDeleteInvalid = async () => { + setIsDeleting(true) + try { + const result = await deleteInvalidRedemptions() + if (result.success) { + const count = result.data || 0 + toast.success( + t('Successfully deleted {{count}} invalid redemption codes', { + count, + }) + ) + triggerRefresh() + setShowDeleteInvalidConfirm(false) + } else { + toast.error(result.message || t(ERROR_MESSAGES.DELETE_INVALID_FAILED)) + } + } finally { + setIsDeleting(false) + } + } + return ( -
- -
+ <> +
+ + +
+ + + {t('This will delete all')} {t('used')},{' '} + {t('disabled')} + {t(', and')} {t('expired')}{' '} + {t('redemption codes.')} +
+ {t('This action cannot be undone.')} + + } + confirmText={t('Delete Invalid')} + /> + ) } diff --git a/web/default/src/features/redemption-codes/components/redemptions-table.tsx b/web/default/src/features/redemption-codes/components/redemptions-table.tsx index ed68f1db..dce04c1c 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-table.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-table.tsx @@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { DISABLED_ROW_DESKTOP, @@ -31,7 +32,11 @@ import { useMediaQuery } from '@/hooks' import { useTableUrlState } from '@/hooks/use-table-url-state' import { getRedemptions, searchRedemptions } from '../api' -import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants' +import { + ERROR_MESSAGES, + REDEMPTION_STATUS, + getRedemptionStatusOptions, +} from '../constants' import { isRedemptionExpired } from '../lib' import type { Redemption } from '../types' import { DataTableBulkActions } from './data-table-bulk-actions' @@ -68,6 +73,11 @@ export function RedemptionsTable() { globalFilter: { enabled: true, key: 'filter' }, columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }], }) + const statusFilter = + (columnFilters.find((filter) => filter.id === 'status')?.value as + | string[] + | undefined) ?? [] + const statusFilterValue = statusFilter[0] ?? '' // Fetch data with React Query const { data, isLoading, isFetching } = useQuery({ @@ -76,18 +86,37 @@ export function RedemptionsTable() { pagination.pageIndex + 1, pagination.pageSize, globalFilter, + statusFilterValue, refreshTrigger, ], queryFn: async () => { const hasFilter = globalFilter?.trim() + const hasStatusFilter = statusFilterValue !== '' const params = { p: pagination.pageIndex + 1, page_size: pagination.pageSize, } - const result = hasFilter - ? await searchRedemptions({ ...params, keyword: globalFilter }) - : await getRedemptions(params) + const result = + hasFilter || hasStatusFilter + ? await searchRedemptions({ + ...params, + keyword: globalFilter, + status: statusFilterValue, + }) + : await getRedemptions(params) + + if (!result.success) { + toast.error( + result.message || + t( + hasFilter || hasStatusFilter + ? ERROR_MESSAGES.SEARCH_FAILED + : ERROR_MESSAGES.LOAD_FAILED + ) + ) + return { items: [], total: 0 } + } return { items: result.data?.items || [], @@ -116,7 +145,8 @@ export function RedemptionsTable() { onPaginationChange, onGlobalFilterChange, onColumnFiltersChange, - manualPagination: !globalFilter, + manualPagination: true, + manualFiltering: true, totalCount: data?.total || 0, ensurePageInRange, }) @@ -149,13 +179,10 @@ export function RedemptionsTable() { }, ], }} - getRowClassName={(row, { isMobile }) => - isDisabledRedemptionRow(row.original) - ? isMobile - ? DISABLED_ROW_MOBILE - : DISABLED_ROW_DESKTOP - : undefined - } + getRowClassName={(row, { isMobile }) => { + if (!isDisabledRedemptionRow(row.original)) return undefined + return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP + }} bulkActions={} /> ) diff --git a/web/default/src/features/redemption-codes/constants.ts b/web/default/src/features/redemption-codes/constants.ts index ef20a2b4..8399e79a 100644 --- a/web/default/src/features/redemption-codes/constants.ts +++ b/web/default/src/features/redemption-codes/constants.ts @@ -16,9 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { type TFunction } from 'i18next' +import type { TFunction } from 'i18next' -import { type StatusBadgeProps } from '@/components/status-badge' +import type { StatusBadgeProps } from '@/components/status-badge' // ============================================================================ // Redemption Status Configuration @@ -63,6 +63,13 @@ export const REDEMPTION_STATUSES: Record< // Note: "Expired" is not a real DB status, it's computed from expired_time export const REDEMPTION_FILTER_EXPIRED = 'expired' +export const REDEMPTION_FILTER_VALUES = [ + String(REDEMPTION_STATUS.ENABLED), + String(REDEMPTION_STATUS.DISABLED), + String(REDEMPTION_STATUS.USED), + REDEMPTION_FILTER_EXPIRED, +] as const + export function getRedemptionStatusOptions(t: TFunction) { return [ ...Object.values(REDEMPTION_STATUSES).map((config) => ({ diff --git a/web/default/src/features/redemption-codes/types.ts b/web/default/src/features/redemption-codes/types.ts index 88bf3d56..611d1983 100644 --- a/web/default/src/features/redemption-codes/types.ts +++ b/web/default/src/features/redemption-codes/types.ts @@ -65,6 +65,7 @@ export interface GetRedemptionsResponse { export interface SearchRedemptionsParams { keyword?: string + status?: string p?: number page_size?: number } diff --git a/web/default/src/routes/_authenticated/redemption-codes/index.tsx b/web/default/src/routes/_authenticated/redemption-codes/index.tsx index a9018d1c..877e006f 100644 --- a/web/default/src/routes/_authenticated/redemption-codes/index.tsx +++ b/web/default/src/routes/_authenticated/redemption-codes/index.tsx @@ -20,7 +20,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import z from 'zod' import { Redemptions } from '@/features/redemption-codes' -import { REDEMPTION_STATUS_VALUES } from '@/features/redemption-codes/constants' +import { REDEMPTION_FILTER_VALUES } from '@/features/redemption-codes/constants' import { ROLE } from '@/lib/roles' import { useAuthStore } from '@/stores/auth-store' @@ -28,7 +28,7 @@ const redemptionsSearchSchema = z.object({ page: z.number().optional().catch(1), pageSize: z.number().optional().catch(10), filter: z.string().optional().catch(''), - status: z.array(z.enum(REDEMPTION_STATUS_VALUES)).optional().catch([]), + status: z.array(z.enum(REDEMPTION_FILTER_VALUES)).optional().catch([]), }) export const Route = createFileRoute('/_authenticated/redemption-codes/')({