Implement billing status notification and enhance user management integration
- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status. - Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates. - Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation. - Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations. - Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
This commit is contained in:
@@ -155,7 +155,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
||||
{user.billingEnabled && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.users.billingLabel')}</Label>
|
||||
<PaidUntilBadge paidUntil={user.billingPaidUntil} showDate />
|
||||
<PaidUntilBadge paidUntil={user.billingPaidUntil} pendingReview={user.billingPendingReview} showDate />
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
@@ -100,7 +100,7 @@ export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: strin
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.status === 'Resolved' && (
|
||||
{data.status === 'Resolved' && data.type === 'BugReport' && (
|
||||
<Button variant="outline" size="sm" disabled={reopenMutation.isPending} onClick={() => reopenMutation.mutate()}>
|
||||
{t('support.reopen')}
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Badge } from '@/shared/ui/badge'
|
||||
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
|
||||
import { listUsers } from '@/features/admin/users/api'
|
||||
import { UserManageDialog } from '@/features/admin/users/UserManageDialog'
|
||||
import type { UserSummaryDto } from '@/shared/api/types'
|
||||
|
||||
export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage })
|
||||
|
||||
@@ -18,12 +17,17 @@ function AdminUsersPage() {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [managing, setManaging] = useState<UserSummaryDto | null>(null)
|
||||
// Id, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит
|
||||
// изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп
|
||||
// диалога от этого не обновится, если хранить готовый объект). Ищем свежую версию в live-данных
|
||||
// запроса на каждый рендер.
|
||||
const [managingId, setManagingId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['admin-users', page, search],
|
||||
queryFn: () => listUsers(page, PAGE_SIZE, search || undefined),
|
||||
})
|
||||
const managingUser = managingId ? (data?.items.find((u) => u.id === managingId) ?? null) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -77,13 +81,17 @@ function AdminUsersPage() {
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{user.billingEnabled ? (
|
||||
<PaidUntilBadge paidUntil={user.billingPaidUntil} showDate />
|
||||
<PaidUntilBadge
|
||||
paidUntil={user.billingPaidUntil}
|
||||
pendingReview={user.billingPendingReview}
|
||||
showDate
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setManaging(user)}>
|
||||
<Button size="sm" variant="outline" onClick={() => setManagingId(user.id)}>
|
||||
{t('admin.users.manage')}
|
||||
</Button>
|
||||
</td>
|
||||
@@ -109,7 +117,9 @@ function AdminUsersPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{managing && <UserManageDialog user={managing} open={!!managing} onOpenChange={(open) => !open && setManaging(null)} />}
|
||||
{managingUser && (
|
||||
<UserManageDialog user={managingUser} open={!!managingUser} onOpenChange={(open) => !open && setManagingId(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ export type UserSummaryDto = {
|
||||
activatedAt: string | null
|
||||
billingEnabled: boolean
|
||||
billingPaidUntil: string | null
|
||||
/** Есть Subscription-заявка на оплату, ожидающая решения админа — конфиги не гасятся, пока он не
|
||||
* решит (см. BillingService). Бейдж должен показывать нейтральный статус, а не тревожный "Истекло". */
|
||||
billingPendingReview: boolean
|
||||
}
|
||||
|
||||
export type RoleDto = {
|
||||
|
||||
@@ -132,6 +132,9 @@ const resources = {
|
||||
requestStatus: {
|
||||
AwaitingPayment: 'Ожидает оплаты',
|
||||
AwaitingConfirmation: 'На проверке у администратора',
|
||||
Confirmed: 'Подтверждена',
|
||||
Rejected: 'Отклонена',
|
||||
Cancelled: 'Отменена',
|
||||
},
|
||||
requisites: 'Реквизиты для оплаты',
|
||||
requisitesMissing: 'Реквизиты ещё не настроены администратором.',
|
||||
@@ -674,6 +677,9 @@ const resources = {
|
||||
requestStatus: {
|
||||
AwaitingPayment: 'Awaiting payment',
|
||||
AwaitingConfirmation: 'Under admin review',
|
||||
Confirmed: 'Confirmed',
|
||||
Rejected: 'Rejected',
|
||||
Cancelled: 'Cancelled',
|
||||
},
|
||||
requisites: 'Payment details',
|
||||
requisitesMissing: 'The administrator has not set up payment details yet.',
|
||||
|
||||
@@ -8,6 +8,7 @@ type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownByt
|
||||
type ConfigStatusChanged = { configId: string; status: ConfigStatus }
|
||||
type UserActivated = { userId: string }
|
||||
type NewsPublished = { id: string; title: string; createdAt: string }
|
||||
type BillingStatusChanged = { userId: string }
|
||||
|
||||
/** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */
|
||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -54,10 +55,15 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['news'] })
|
||||
}
|
||||
|
||||
const onBillingStatusChanged = (_payload: BillingStatusChanged) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['my-billing-status'] })
|
||||
}
|
||||
|
||||
connection.on('configTrafficUpdated', onTrafficUpdated)
|
||||
connection.on('configStatusChanged', onStatusChanged)
|
||||
connection.on('userActivated', onUserActivated)
|
||||
connection.on('newsPublished', onNewsPublished)
|
||||
connection.on('billingStatusChanged', onBillingStatusChanged)
|
||||
|
||||
void startConnection()
|
||||
|
||||
@@ -66,6 +72,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
connection.off('configStatusChanged', onStatusChanged)
|
||||
connection.off('userActivated', onUserActivated)
|
||||
connection.off('newsPublished', onNewsPublished)
|
||||
connection.off('billingStatusChanged', onBillingStatusChanged)
|
||||
}
|
||||
}, [user, queryClient])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user