Refactor pricing endpoints and enhance support for pricing retrieval
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Added a new endpoint `/api/support/pricing` to allow users to retrieve pricing information, making it accessible for role request dialogs.
- Introduced the `GetSupportPricing` method to handle pricing queries, ensuring that pricing data is available to non-admin users.
- Updated frontend components to integrate the new pricing retrieval functionality, displaying estimated costs based on user-selected configurations.
- Removed the `PricingSettingsDto` as it is no longer needed, streamlining the pricing data structure.
- Enhanced API documentation to reflect the new endpoint and its usage in the support context.
This commit is contained in:
Leonid Pershin
2026-07-18 21:19:40 +03:00
parent 32221af503
commit 3304eed4b3
14 changed files with 127 additions and 24 deletions
@@ -1,5 +1,6 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Pricing; using PnvPanel.Application.Admin.Pricing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
@@ -8,6 +8,7 @@ using PnvPanel.Application.Support.AddComment;
using PnvPanel.Application.Support.CreateBugReport; using PnvPanel.Application.Support.CreateBugReport;
using PnvPanel.Application.Support.CreateRoleRequest; using PnvPanel.Application.Support.CreateRoleRequest;
using PnvPanel.Application.Support.GetAttachment; using PnvPanel.Application.Support.GetAttachment;
using PnvPanel.Application.Support.GetSupportPricing;
using PnvPanel.Application.Support.GetTicket; using PnvPanel.Application.Support.GetTicket;
using PnvPanel.Application.Support.ListMyTickets; using PnvPanel.Application.Support.ListMyTickets;
using PnvPanel.Application.Support.ListSelectableRoles; using PnvPanel.Application.Support.ListSelectableRoles;
@@ -23,6 +24,7 @@ public static class SupportEndpoints
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization(); var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>(); group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
group.MapGet("/pricing", GetSupportPricing).Produces<PricingSettingsDto>();
group group
.MapPost("/tickets/bug-reports", CreateBugReport) .MapPost("/tickets/bug-reports", CreateBugReport)
.DisableAntiforgery() .DisableAntiforgery()
@@ -49,6 +51,15 @@ public static class SupportEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetSupportPricing(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetSupportPricingQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateBugReport( private static async Task<IResult> CreateBugReport(
[FromForm] string message, [FromForm] string message,
IFormFileCollection? files, IFormFileCollection? files,
@@ -1,3 +1,4 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -1,17 +0,0 @@
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Admin.Pricing;
public sealed record PricingSettingsDto(
int? PricePerConfigPerQuarter,
int? PricePerConfigPerHalfYear,
int? PricePerConfigPerYear
)
{
public static PricingSettingsDto FromDomain(PricingSettings settings) =>
new(
settings.PricePerConfigPerQuarter,
settings.PricePerConfigPerHalfYear,
settings.PricePerConfigPerYear
);
}
@@ -1,3 +1,4 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -0,0 +1,20 @@
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>Все поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период =
/// ставка × число месяцев. Используется и Admin (редактирование), и Support (справка при заявке на
/// роль) — см. PricingSettingsDto.FromDomain.</summary>
public sealed record PricingSettingsDto(
int? PricePerConfigPerQuarter,
int? PricePerConfigPerHalfYear,
int? PricePerConfigPerYear
)
{
public static PricingSettingsDto FromDomain(PricingSettings settings) =>
new(
settings.PricePerConfigPerQuarter,
settings.PricePerConfigPerHalfYear,
settings.PricePerConfigPerYear
);
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.GetSupportPricing;
/// <summary>Справочная цена конфига для заявки на роль (не редактирование, только чтение) — в отличие
/// от GetPricingSettingsQuery (Admin/Pricing), доступна любому активированному пользователю.</summary>
public sealed record GetSupportPricingQuery : IQuery<Result<PricingSettingsDto>>, IRequiresActivation;
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.GetSupportPricing;
public sealed class GetSupportPricingQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetSupportPricingQuery, Result<PricingSettingsDto>>
{
public async Task<Result<PricingSettingsDto>> Handle(
GetSupportPricingQuery query,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.PricingSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
return Result.Success(
settings is null
? new PricingSettingsDto(null, null, null)
: PricingSettingsDto.FromDomain(settings)
);
}
}
+1
View File
@@ -156,6 +156,7 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| Метод | Путь | Тело запроса | Тело ответа | | Метод | Путь | Тело запроса | Тело ответа |
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- | | ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке | | GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке |
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена, что и `/api/admin/pricing`, для справки в диалоге заявки на роль |
| GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) | | GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) |
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) | | GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` | | POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
+11 -5
View File
@@ -6,7 +6,8 @@
Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота: Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота:
число активных конфигов на роль (`AppRole.MaxConfigs`). Есть глобальная справочная цена за один число активных конфигов на роль (`AppRole.MaxConfigs`). Есть глобальная справочная цена за один
конфиг (`PricingSettings`, видна только админу) — это не биллинг: без статусов оплаты, дат окончания конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным пользователям
в заявке на роль) — это не биллинг: без статусов оплаты, дат окончания
и интеграций с платёжными системами, см. ниже. и интеграций с платёжными системами, см. ниже.
## Диаграмма связей ## Диаграмма связей
@@ -328,10 +329,15 @@ PricePerConfigPerQuarter × 3` и `PricePerConfigPerYear × 12 ≥ PricePerConfi
полугодовая ставка не задана — год сверяется напрямую с кварталом: `PricePerConfigPerYear × 12 ≥ полугодовая ставка не задана — год сверяется напрямую с кварталом: `PricePerConfigPerYear × 12 ≥
PricePerConfigPerQuarter × 3`). PricePerConfigPerQuarter × 3`).
`GET/PUT /api/admin/pricing` — только `admin` (в отличие от `RoleDto`, цена никогда не попадает в `GET/PUT /api/admin/pricing` — только `admin` (редактирование). `GET /api/support/pricing` — то же
`GET /api/support/roles`, доступный любому активированному пользователю, — это два независимых DTO). чтение, но доступно любому активированному пользователю (не `admin`-эндпоинт) — используется в
Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после диалоге заявки на роль, чтобы показать ориентировочную стоимость выбранной/предлагаемой роли, с
полного сброса панели (см. «Полный сброс панели» выше). пометкой, что цены пока ознакомительные. Это два разных Query (`GetPricingSettingsQuery` в
`Admin/Pricing`, `GetSupportPricingQuery` в `Support`) над одним и тем же общим `PricingSettingsDto`
(`Common/Interfaces`) — по аналогии с `ListRolesQuery`/`ListSelectableRolesQuery` для ролей. В отличие
от `RoleDto`, у `PricingSettingsDto` нет чувствительных per-роль данных, поэтому шарить DTO между
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
### ActivationRequest — запрос активации ### ActivationRequest — запрос активации
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram. Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
+1 -1
View File
@@ -75,7 +75,7 @@
| -------------------------- | --------------------------------------------------------------------------------- | | -------------------------- | --------------------------------------------------------------------------------- |
| Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) | | Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) |
| Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) | | Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, видна только админу) — без биллинг-логики | | Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, редактирует только `admin`, справочно видна и в заявке на роль) — без биллинг-логики |
| i18n | RU + EN (react-i18next) | | i18n | RU + EN (react-i18next) |
| Telegram-транспорт | Long polling | | Telegram-транспорт | Long polling |
| Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) | | Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) |
@@ -9,7 +9,7 @@ import { Label } from '@/shared/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
import { createRoleRequestTicket, listSelectableRoles } from './api' import { createRoleRequestTicket, getSupportPricing, listSelectableRoles } from './api'
type Mode = 'existing' | 'new' type Mode = 'existing' | 'new'
@@ -25,6 +25,7 @@ export function CreateRoleRequestDialog() {
const [justification, setJustification] = useState('') const [justification, setJustification] = useState('')
const rolesQuery = useQuery({ queryKey: ['selectable-roles'], queryFn: listSelectableRoles, enabled: open }) const rolesQuery = useQuery({ queryKey: ['selectable-roles'], queryFn: listSelectableRoles, enabled: open })
const pricingQuery = useQuery({ queryKey: ['support-pricing'], queryFn: getSupportPricing, enabled: open })
const resetForm = () => { const resetForm = () => {
setMode('existing') setMode('existing')
@@ -70,6 +71,23 @@ export function CreateRoleRequestDialog() {
? roleId.length > 0 ? roleId.length > 0
: newRoleName.trim().length > 0 && newRoleMaxConfigs !== '' && newRoleMaxIpLimit !== '') : newRoleName.trim().length > 0 && newRoleMaxConfigs !== '' && newRoleMaxIpLimit !== '')
// Квота, для которой считаем ориентировочную стоимость: у существующей роли — её maxConfigs,
// у новой — то, что пользователь ввёл (пока не введено или отрицательное кроме -1 — не считаем).
const maxConfigsForPricing =
mode === 'existing'
? rolesQuery.data?.find((role) => role.id === roleId)?.maxConfigs
: newRoleMaxConfigs !== '' && Number.isFinite(Number(newRoleMaxConfigs))
? Number(newRoleMaxConfigs)
: undefined
// Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота.
const totalPrice = (monthlyRate: number | null | undefined, months: number) =>
monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0
? t('admin.roles.noPrice')
: `${monthlyRate * months * maxConfigsForPricing}`
const showPricing = maxConfigsForPricing != null && maxConfigsForPricing >= 0
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -146,6 +164,16 @@ export function CreateRoleRequestDialog() {
</> </>
)} )}
{showPricing && (
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-sm">
<p className="font-medium">{t('support.pricingTitle')}</p>
<p>{t('support.pricingQuarter', { price: totalPrice(pricingQuery.data?.pricePerConfigPerQuarter, 3) })}</p>
<p>{t('support.pricingHalfYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerHalfYear, 6) })}</p>
<p>{t('support.pricingYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerYear, 12) })}</p>
<p className="text-xs text-muted-foreground">{t('support.pricingDisclaimer')}</p>
</div>
)}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="justification">{t('support.justification')}</Label> <Label htmlFor="justification">{t('support.justification')}</Label>
<Textarea id="justification" value={justification} onChange={(e) => setJustification(e.target.value)} required /> <Textarea id="justification" value={justification} onChange={(e) => setJustification(e.target.value)} required />
+5
View File
@@ -1,6 +1,7 @@
import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client' import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
import type { import type {
PagedList, PagedList,
PricingSettingsDto,
RoleDto, RoleDto,
TicketCommentDto, TicketCommentDto,
TicketDetailDto, TicketDetailDto,
@@ -28,6 +29,10 @@ export function listSelectableRoles() {
return apiRequest<RoleDto[]>('/support/roles') return apiRequest<RoleDto[]>('/support/roles')
} }
export function getSupportPricing() {
return apiRequest<PricingSettingsDto>('/support/pricing')
}
export function createBugReportTicket(message: string, files: File[]) { export function createBugReportTicket(message: string, files: File[]) {
const formData = new FormData() const formData = new FormData()
formData.set('message', message) formData.set('message', message)
+10
View File
@@ -135,6 +135,11 @@ const resources = {
newRoleName: 'Название роли', newRoleName: 'Название роли',
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)', newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)', newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
pricingTitle: 'Ориентировочная стоимость',
pricingQuarter: '3 месяца: {{price}}',
pricingHalfYear: 'Полгода: {{price}}',
pricingYear: 'Год: {{price}}',
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
justification: 'Обоснование', justification: 'Обоснование',
ticketCreated: 'Обращение отправлено.', ticketCreated: 'Обращение отправлено.',
roleRequestPending: 'У вас уже есть необработанная заявка на роль.', roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
@@ -571,6 +576,11 @@ const resources = {
newRoleName: 'Role name', newRoleName: 'Role name',
newRoleMaxConfigs: 'Max configs (-1 = unlimited)', newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)', newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
pricingTitle: 'Estimated cost',
pricingQuarter: '3 months: {{price}}',
pricingHalfYear: '6 months: {{price}}',
pricingYear: 'Year: {{price}}',
pricingDisclaimer: 'Prices are indicative only at this time.',
justification: 'Justification', justification: 'Justification',
ticketCreated: 'Ticket submitted.', ticketCreated: 'Ticket submitted.',
roleRequestPending: 'You already have a pending role request.', roleRequestPending: 'You already have a pending role request.',