Files
portal/app/resources/js/api/admin.ts
T
Дмитрий c52fedae9d feat(смс-клиент): имя отправителя регистрируем мы — разрешение по образцу + документ-основание
Имя у оператора регистрирует Лидерра от лица клиента, поэтому от клиента два
скана: подписанное разрешение и документ-основание. Оба обязательны, галочка
согласия убрана.

- Бланк разрешения — PDF по нашему образцу «Разрешение-домен УНИВЕРСАЛЬНОЕ»:
  Правообладатель = клиент, Пользователь = наш ИП из legal_entities is_default.
  4 вида имени домен/юрлицо/ИП/товарный знак — у каждого своё основание права.
- Физлицо: домен часто на физлицо — гейт по ФИО, паспорт вписывается от руки,
  паспорт не храним 152-ФЗ.
- Две колонки client_sms_senders: doc_* документ-основание, consent_doc_*
  подписанное разрешение.
- Админка: скачивание обоих сканов, вид .../document/basis и .../document/consent.
- Гейт реквизитов requisites_ready; эндпоинт GET /api/sms/sender/consent-form.

Тесты: ClientSms backend 95, фронт СМС 43, pint чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 14:38:21 +03:00

776 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-вызовы для админских endpoint'ов SaaS (см. ImpersonationController).
*
* На MVP вызываются без auth:saas-admin middleware (см. routes/web.php).
* Production: middleware('auth:saas-admin') + cookie session — apiClient уже
* настроен на withCredentials.
*/
export interface ImpersonationInitPayload {
tenant_id: number;
requested_by: number; // на MVP параметром; на prod — request()->user()->id
reason: string; // ≥30 chars (валидируется на backend)
}
export interface ImpersonationInitResponse {
token_id: number;
expires_at: string; // ISO8601
sent_to_email: string;
/** dev-only: исчезнет после интеграции MailService на prod */
_dev_plain_code?: string;
}
export async function impersonationInit(payload: ImpersonationInitPayload): Promise<ImpersonationInitResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ImpersonationInitResponse>('/api/admin/impersonation/init', payload);
return data;
}
export interface ImpersonationVerifyPayload {
token_id: number;
code: string; // 6 цифр
}
export interface ImpersonationVerifyResponse {
token_id: number;
tenant_id: number;
used_at: string;
message: string;
machine_token: string;
expires_at: string;
}
export async function impersonationVerify(payload: ImpersonationVerifyPayload): Promise<ImpersonationVerifyResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ImpersonationVerifyResponse>('/api/admin/impersonation/verify', payload);
return data;
}
export interface ImpersonationEndResponse {
token_id: number;
session_ended_at: string;
message: string;
}
export async function impersonationEnd(tokenId: number): Promise<ImpersonationEndResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ImpersonationEndResponse>('/api/admin/impersonation/end', {
token_id: tokenId,
});
return data;
}
export interface ImpersonationActiveSession {
token_id: number;
tenant_id: number;
tenant_name: string | null;
requested_by: number;
reason: string;
sent_to_email: string;
used_at: string;
expires_at: string;
}
export interface ImpersonationRecentSession {
token_id: number;
tenant_id: number;
tenant_name: string | null;
requested_by: number;
reason: string;
used_at: string;
session_ended_at: string;
duration_seconds: number | null;
}
export async function impersonationActive(): Promise<ImpersonationActiveSession[]> {
const { data } = await apiClient.get<{ sessions: ImpersonationActiveSession[] }>('/api/admin/impersonation/active');
return data.sessions;
}
export async function impersonationRecent(): Promise<ImpersonationRecentSession[]> {
const { data } = await apiClient.get<{ sessions: ImpersonationRecentSession[] }>('/api/admin/impersonation/recent');
return data.sessions;
}
// === SaaS-admin → Тенанты: lookup для AdminTenantsView ===
export interface AdminTenant {
id: number;
subdomain: string;
organization_name: string;
contact_email: string;
status: string;
balance_rub: string;
balance_leads: number;
is_trial: boolean;
last_activity_at: string | null;
tariff_id: number | null;
tariff_name: string | null;
/** price_monthly активного тарифа если не-trial; иначе null. */
mrr_rub: string | null;
desired_daily_numbers: number | null;
chargeback_unrecovered_rub: string;
created_at: string | null;
}
interface AdminTenantsStats {
total: number;
active: number;
trial: number;
overdue: number;
}
export interface ListAdminTenantsParams {
status?: string;
/** Производные статусы UI (trial/overdue/active/suspended), csv — серверный multi-фильтр. */
statuses?: string;
/** Имена тарифов (tariff_plans.name), csv — серверный multi-фильтр. */
tariffs?: string;
search?: string;
limit?: number;
offset?: number;
}
export interface ListAdminTenantsResponse {
tenants: AdminTenant[];
total: number;
limit: number;
offset: number;
stats: AdminTenantsStats;
}
export async function listAdminTenants(params: ListAdminTenantsParams = {}): Promise<ListAdminTenantsResponse> {
const { data } = await apiClient.get<ListAdminTenantsResponse>('/api/admin/tenants', { params });
return data;
}
// === SaaS-admin → Тенанты → детали (для AdminTenantDetailView) ===
export interface ApiTenantUser {
id: number;
email: string;
first_name: string | null;
last_name: string | null;
is_active: boolean;
totp_enabled: boolean;
last_active_at: string | null;
last_login_at: string | null;
}
export interface ApiTenantProject {
id: number;
name: string;
tag: string | null;
is_active: boolean;
daily_limit_target: number;
suppliers_count: number;
leads_today: number;
}
export interface ApiTenantBalanceTx {
id: number;
type: string;
amount_rub: string;
amount_leads: number;
balance_rub_after: string | null;
description: string | null;
created_at: string;
}
export interface ApiTenantActivityEvent {
id: number;
event: string;
deal_id: number;
actor_email: string | null;
context: Record<string, unknown> | null;
created_at: string;
}
interface ApiTenantMetrics {
leads_today: number;
leads_this_week: number;
leads_this_month: number;
avg_lead_cost_rub: number | null;
runway_days: number | null;
}
export interface AdminTenantDetailResponse {
tenant: AdminTenant;
users: ApiTenantUser[];
projects: ApiTenantProject[];
balance_history: ApiTenantBalanceTx[];
activity: ApiTenantActivityEvent[];
metrics: ApiTenantMetrics;
}
export async function getAdminTenantDetail(subdomain: string): Promise<AdminTenantDetailResponse> {
const { data } = await apiClient.get<AdminTenantDetailResponse>(
`/api/admin/tenants/${encodeURIComponent(subdomain)}`,
);
return data;
}
// === SaaS-admin → Биллинг: aggregates пополнений/списаний ===
export interface ApiAdminBillingTenant {
id: number;
subdomain: string;
organization_name: string;
contact_email: string;
status: string;
balance_rub: string;
tariff_id: number | null;
tariff_name: string | null;
mrr_rub: string;
monthly_topups_rub: string;
monthly_charges_rub: string;
last_payment_at: string | null;
chargeback_unrecovered_rub: string;
}
interface ApiAdminBillingSummary {
total_mrr_rub: string;
monthly_revenue_rub: string;
overdue_count: number;
refunds_count_30d: number;
}
export interface ListAdminBillingResponse {
tenants: ApiAdminBillingTenant[];
summary: ApiAdminBillingSummary;
}
export async function listAdminBilling(search = ''): Promise<ListAdminBillingResponse> {
const { data } = await apiClient.get<ListAdminBillingResponse>('/api/admin/billing', {
params: { search },
});
return data;
}
// === SaaS-admin → Реклама: расход и маржа по тенантам ===
export type AdvertisingSpendPeriod = 'current_month' | 'last_month' | '90d' | 'all';
export interface ApiAdminAdvertisingSpendRow {
tenant_id: number;
tenant_name: string;
client_spend_rub: string;
yandex_cost_rub: string;
our_margin_rub: string;
}
export interface ApiAdminAdvertisingSpendTotals {
client_spend_rub: string;
yandex_cost_rub: string;
our_margin_rub: string;
}
export interface AdminAdvertisingSpendResponse {
data: ApiAdminAdvertisingSpendRow[];
totals: ApiAdminAdvertisingSpendTotals;
markup_percent: number;
}
export async function fetchAdvertisingSpend(
period: AdvertisingSpendPeriod = 'current_month',
): Promise<AdminAdvertisingSpendResponse> {
const { data } = await apiClient.get<AdminAdvertisingSpendResponse>('/api/admin/advertising/spend', {
params: { period },
});
return data;
}
// === SaaS-admin → Инциденты ===
export interface ApiAdminIncident {
id: number;
incident_id: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
summary: string;
started_at: string;
detected_at: string;
resolved_at: string | null;
status: 'open' | 'investigating' | 'resolved';
affected_tenants_count: number;
affected_users_count: number | null;
rkn_notified: boolean;
rkn_notified_at: string | null;
rkn_deadline_at: string | null;
}
interface ApiAdminIncidentsSummary {
open: number;
investigating: number;
rkn_pending: number;
total_unresolved: number;
}
export interface ListAdminIncidentsParams {
type?: string;
severity?: string;
unresolved_only?: boolean;
limit?: number;
offset?: number;
}
export interface ListAdminIncidentsResponse {
incidents: ApiAdminIncident[];
total: number;
limit: number;
offset: number;
summary: ApiAdminIncidentsSummary;
}
export async function listAdminIncidents(params: ListAdminIncidentsParams = {}): Promise<ListAdminIncidentsResponse> {
const { data } = await apiClient.get<ListAdminIncidentsResponse>('/api/admin/incidents', { params });
return data;
}
// === SaaS-admin → Система: system_settings edit-flow ===
export interface SystemSetting {
key: string;
value: string;
type: 'int' | 'string' | 'decimal' | 'bool' | 'json';
description: string | null;
updated_at: string;
updated_by: number | null;
}
export async function listSystemSettings(): Promise<SystemSetting[]> {
const { data } = await apiClient.get<{ settings: SystemSetting[] }>('/api/admin/system-settings');
return data.settings;
}
export interface Yandex360BalanceStatus {
balance: number | null;
updated_at: string | null;
}
/** Ручной баланс Яндекс 360 (почта): текущее значение + когда обновляли. */
export async function getYandex360Balance(): Promise<Yandex360BalanceStatus> {
const { data } = await apiClient.get<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance');
return data;
}
/** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */
export async function setYandex360Balance(balance: number | null): Promise<Yandex360BalanceStatus> {
await ensureCsrfCookie();
const { data } = await apiClient.put<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance', { balance });
return data;
}
export interface UpdateSystemSettingPayload {
value: string;
reason: string; // ≥30 chars
admin_user_id?: number; // опц.: id админа проставляет бэкенд из сессии (saas_admin auth); клиент не диктует
}
export interface UpdateSystemSettingResponse {
key: string;
value: string;
previous_value: string;
updated_at: string;
message: string;
}
export async function updateSystemSetting(
key: string,
payload: UpdateSystemSettingPayload,
): Promise<UpdateSystemSettingResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.put<UpdateSystemSettingResponse>(
`/api/admin/system-settings/${encodeURIComponent(key)}`,
payload,
);
return data;
}
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===
export interface AdminTariffPlan {
id: number;
name: string;
price_monthly: string;
}
export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
return data.plans;
}
export async function updateTenantStatus(
id: number,
status: 'active' | 'suspended',
reason: string,
): Promise<{ id: number; status: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; status: string }>(`/api/admin/billing/tenants/${id}/status`, {
status,
reason,
});
return data;
}
export async function refundTenant(
id: number,
amountRub: number,
reason: string,
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
`/api/admin/billing/tenants/${id}/refund`,
{ amount_rub: amountRub, reason },
);
return data;
}
export async function updateTenantBalance(
id: number,
payload: { balance_rub: string; reason?: string },
): Promise<{ id: number; balance_rub: string; delta: string; transaction_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{
id: number;
balance_rub: string;
delta: string;
transaction_id: number;
}>(`/api/admin/tenants/${id}/balance`, payload);
return data;
}
export async function changeTenantTariff(
id: number,
tariffId: number,
reason: string,
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
`/api/admin/billing/tenants/${id}/tariff`,
{ tariff_id: tariffId, reason },
);
return data;
}
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
export interface ApiIncidentAffectedTenant {
id: number;
organization_name: string;
}
export interface ApiAdminIncidentDetail {
id: number;
incident_id: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
summary: string;
root_cause: string | null;
postmortem_url: string | null;
started_at: string;
detected_at: string;
resolved_at: string | null;
status: 'open' | 'investigating' | 'resolved';
affected_tenants: ApiIncidentAffectedTenant[];
affected_users_count: number | null;
notification_sent_at: string | null;
rkn_notified: boolean;
rkn_notified_at: string | null;
rkn_deadline_at: string | null;
created_by_admin: string | null;
closed_by_admin: string | null;
created_at: string | null;
updated_at: string | null;
}
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
return data.incident;
}
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
`/api/admin/incidents/${id}/rkn-notify`,
{},
);
return data.incident;
}
// === SaaS-admin → Тарифная сетка (Plan 4 / Sprint 5C G3) ===
export interface AdminPricingTier {
tier_no: number;
leads_in_tier: number | null;
price_per_lead_kopecks: number;
effective_from: string;
}
export interface PricingTiersResponse {
active: AdminPricingTier[];
scheduled: Record<string, AdminPricingTier[]>;
}
export interface PricingTierEditorRow {
tier_no: number;
leads_in_tier: number | null;
price_rub: string;
}
export async function getPricingTiers(): Promise<PricingTiersResponse> {
const { data } = await apiClient.get<{ data: PricingTiersResponse }>('/api/admin/pricing-tiers');
return { active: data.data.active, scheduled: data.data.scheduled ?? {} };
}
export async function createPricingTiers(
tiers: PricingTierEditorRow[],
effectiveFrom?: string,
): Promise<{ effective_from: string }> {
await ensureCsrfCookie();
const payload: { tiers: PricingTierEditorRow[]; effective_from?: string } = { tiers };
if (effectiveFrom) payload.effective_from = effectiveFrom;
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', payload);
return data;
}
export async function deleteScheduledPricingTier(effectiveFrom: string): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
}
// === SaaS-admin → Цены поставщиков (Plan 4 / Sprint 5C G3) ===
export interface AdminSupplier {
id: number;
code: string;
name: string;
cost_rub: string;
quality_score: string;
is_active: boolean;
}
export async function getAdminSuppliers(): Promise<AdminSupplier[]> {
const { data } = await apiClient.get<{ data: AdminSupplier[] }>('/api/admin/suppliers');
return data.data;
}
export async function updateAdminSupplier(
id: number,
payload: { cost_rub: string; quality_score: string; is_active: boolean },
): Promise<AdminSupplier> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
return data.data;
}
// ---------------------------------------------------------------------------
// 152-ФЗ: обращения субъектов ПДн
// ---------------------------------------------------------------------------
export interface PdSubjectRequest {
id: number;
received_at: string;
subject_email: string | null;
subject_phone: string | null;
subject_full_name: string | null;
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
description: string | null;
status: 'received' | 'in_progress' | 'completed' | 'rejected';
tenant_id: number | null;
assigned_admin_id: number | null;
response_text: string | null;
deadline_at: string;
completed_at: string | null;
processing_restricted: boolean;
}
export interface ListPdRequestsResponse {
data: PdSubjectRequest[];
total: number;
limit: number;
offset: number;
}
export interface CreatePdRequestPayload {
subject_email?: string;
subject_phone?: string;
subject_full_name?: string;
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
description?: string;
tenant_id?: number | null;
}
export interface EraseSubjectResult {
message: string;
counts: { users: number; leads: number; deals: number; webhook_log: number };
}
export async function listPdSubjectRequests(
params: { status?: string; request_type?: string; limit?: number; offset?: number } = {},
): Promise<ListPdRequestsResponse> {
const { data } = await apiClient.get<ListPdRequestsResponse>('/api/admin/pd-subject-requests', { params });
return data;
}
export async function createPdSubjectRequest(payload: CreatePdRequestPayload): Promise<PdSubjectRequest> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ data: PdSubjectRequest }>('/api/admin/pd-subject-requests', payload);
return data.data;
}
export async function executePdErasure(id: number, adminUserId?: number): Promise<EraseSubjectResult> {
await ensureCsrfCookie();
const payload = adminUserId !== undefined ? { admin_user_id: adminUserId } : {};
const { data } = await apiClient.post<EraseSubjectResult>(`/api/admin/pd-subject-requests/${id}/erase`, payload);
return data;
}
// --- Оплата по счёту (Этап 1): список счетов + ручная отметка оплаты ---
export interface AdminInvoiceRow {
id: number;
invoice_number: string;
amount_total: string;
status: string;
issued_at: string;
expires_at: string | null;
tenant_id: number;
tenant_name: string | null;
payer_name: string | null;
}
export interface ListAdminInvoicesParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
}
export interface ListAdminInvoicesResponse {
data: AdminInvoiceRow[];
meta: { current_page: number; last_page: number; total: number; per_page: number };
}
export async function listAdminInvoices(params: ListAdminInvoicesParams = {}): Promise<ListAdminInvoicesResponse> {
const { data } = await apiClient.get<ListAdminInvoicesResponse>('/api/admin/invoices', { params });
return data;
}
export async function markInvoicePaid(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
}
// === SaaS-admin → СМС: тарифы и имена (Task 16b) ===
/** Ступень тарифа СМС (строка GET /api/admin/sms/tariffs, а также PUT-ответ). */
export interface AdminSmsTariff {
id: number;
min_qty: number;
price_rub: string;
}
/** Строка редактируемой ступени в PUT /api/admin/sms/tariffs (без id — набор заменяется целиком). */
export interface AdminSmsTariffRow {
min_qty: number;
price_rub: string;
}
/** Настройки платного имени отправителя (часть GET /api/admin/sms/tariffs, а также PUT /api/admin/sms/settings). */
export interface AdminSmsSettings {
name_fee_rub_per_operator: string;
name_debt_grace_days: number;
}
/** Ответ GET /api/admin/sms/tariffs. */
export interface AdminSmsTariffsResponse {
tariffs: AdminSmsTariff[];
settings: AdminSmsSettings;
}
/** Ответ PUT /api/admin/sms/tariffs — заменённый набор ступеней. */
export interface AdminSmsTariffsUpdateResponse {
tariffs: AdminSmsTariff[];
}
/** Заявка на имя отправителя (строка GET /api/admin/sms/senders, а также ответ approve/reject/disable). */
export interface AdminSmsSender {
id: number;
tenant_id: number;
name: string;
name_type: string;
status: string;
monthly_fee_rub: string;
note: string | null;
doc_original_name: string | null;
consent_doc_original_name: string | null;
}
/**
* Ссылка на скачивание приложенного клиентом скана. kind: 'basis' —
* документ-основание (право на имя), 'consent' — подписанное согласие.
*/
export function smsSenderDocumentUrl(id: number, kind: 'basis' | 'consent'): string {
return `/api/admin/sms/senders/${id}/document/${kind}`;
}
/** GET /api/admin/sms/tariffs — ступени тарифа + настройки платного имени. */
export async function fetchSmsTariffs(): Promise<AdminSmsTariffsResponse> {
const { data } = await apiClient.get<AdminSmsTariffsResponse>('/api/admin/sms/tariffs');
return data;
}
/** PUT /api/admin/sms/tariffs — заменить весь набор ступеней тарифа СМС. */
export async function updateSmsTariffs(rows: AdminSmsTariffRow[]): Promise<AdminSmsTariffsUpdateResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminSmsTariffsUpdateResponse>('/api/admin/sms/tariffs', { rows });
return data;
}
/** PUT /api/admin/sms/settings — плата за имя/мес и срок долга до отключения. */
export async function updateSmsSettings(
nameFeeRubPerOperator: string,
nameDebtGraceDays: number,
): Promise<AdminSmsSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminSmsSettings>('/api/admin/sms/settings', {
name_fee_rub_per_operator: nameFeeRubPerOperator,
name_debt_grace_days: nameDebtGraceDays,
});
return data;
}
/** GET /api/admin/sms/senders?status=... — список заявок на имя отправителя. */
export async function fetchSmsSenders(status?: string): Promise<AdminSmsSender[]> {
const { data } = await apiClient.get<{ senders: AdminSmsSender[] }>('/api/admin/sms/senders', {
params: status ? { status } : {},
});
return data.senders;
}
/** POST /api/admin/sms/senders/{id}/approve — подтвердить имя отправителя. */
export async function approveSmsSender(id: number): Promise<AdminSmsSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/approve`);
return data;
}
/** POST /api/admin/sms/senders/{id}/reject — отклонить имя отправителя (с опциональной причиной). */
export async function rejectSmsSender(id: number, note?: string): Promise<AdminSmsSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/reject`, {
...(note !== undefined ? { note } : {}),
});
return data;
}
/** POST /api/admin/sms/senders/{id}/disable — отключить активное имя отправителя. */
export async function disableSmsSender(id: number): Promise<AdminSmsSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/disable`);
return data;
}