Refactor pricing endpoints and enhance support for pricing retrieval
- 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:
@@ -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;
|
||||||
+27
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user