Commit 12603a77 by CaIon

fix(redemption): add status filtering and cleanup action

parent 86021d8e
......@@ -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
......
......@@ -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
......
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)
})
}
}
......@@ -44,10 +44,13 @@ export async function getRedemptions(
export async function searchRedemptions(
params: SearchRedemptionsParams
): Promise<GetRedemptionsResponse> {
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
}
......
......@@ -16,25 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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<TData> = {
table: Table<TData>
......@@ -44,11 +33,7 @@ export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
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<TData>({
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 (
<>
<BulkActionsToolbar table={table} entityName={t('redemption code')}>
<CopyButton
value={contentToCopy}
variant='outline'
size='icon'
className='size-8'
tooltip={t('Copy selected codes')}
successTooltip={t('Codes copied!')}
aria-label={t('Copy selected codes')}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteInvalidConfirm(true)}
className='size-8'
aria-label={t('Delete invalid redemption codes')}
title={t('Delete invalid redemption codes')}
/>
}
>
<Trash2 />
<span className='sr-only'>{t('Delete invalid codes')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Delete invalid codes (used/disabled/expired)')}</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
<ConfirmDialog
destructive
open={showDeleteInvalidConfirm}
onOpenChange={setShowDeleteInvalidConfirm}
handleConfirm={handleDeleteInvalid}
isLoading={isDeleting}
className='max-w-md'
title={t('Delete Invalid Redemption Codes?')}
desc={
<>
{t('This will delete all')} <strong>{t('used')}</strong>,{' '}
<strong>{t('disabled')}</strong>
{t(', and')} <strong>{t('expired')}</strong>{' '}
{t('redemption codes.')}
<br />
{t('This action cannot be undone.')}
</>
}
confirmText={t('Delete Invalid')}
<BulkActionsToolbar table={table} entityName={t('redemption code')}>
<CopyButton
value={contentToCopy}
variant='outline'
size='icon'
className='size-8'
tooltip={t('Copy selected codes')}
successTooltip={t('Codes copied!')}
aria-label={t('Copy selected codes')}
/>
</>
</BulkActionsToolbar>
)
}
......@@ -16,22 +16,83 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 (
<div className='flex gap-2'>
<Button size='sm' onClick={() => setOpen('create')}>
<Plus className='h-4 w-4' />
{t('Create Code')}
</Button>
</div>
<>
<div className='flex flex-wrap gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => setShowDeleteInvalidConfirm(true)}
>
<Trash2 className='text-destructive h-4 w-4' />
{t('Delete Invalid')}
</Button>
<Button size='sm' onClick={() => setOpen('create')}>
<Plus className='h-4 w-4' />
{t('Create Code')}
</Button>
</div>
<ConfirmDialog
destructive
open={showDeleteInvalidConfirm}
onOpenChange={setShowDeleteInvalidConfirm}
handleConfirm={handleDeleteInvalid}
isLoading={isDeleting}
className='max-w-md'
title={t('Delete Invalid Redemption Codes?')}
desc={
<>
{t('This will delete all')} <strong>{t('used')}</strong>,{' '}
<strong>{t('disabled')}</strong>
{t(', and')} <strong>{t('expired')}</strong>{' '}
{t('redemption codes.')}
<br />
{t('This action cannot be undone.')}
</>
}
confirmText={t('Delete Invalid')}
/>
</>
)
}
......@@ -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={<DataTableBulkActions table={table} />}
/>
)
......
......@@ -16,9 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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) => ({
......
......@@ -65,6 +65,7 @@ export interface GetRedemptionsResponse {
export interface SearchRedemptionsParams {
keyword?: string
status?: string
p?: number
page_size?: number
}
......
......@@ -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/')({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment