Files
portal/app/resources/js/api/admin.ts
T
Дмитрий 5b04d751c0 feat(телеграм-модуль): авто-режим, своё имя + помесячная оплата, админ-тарифы
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан.

4.1 Авто-режим (пачка):
- Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS).
- TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик
  (audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold,
  по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub
  и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе.
  Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе).

4.2 Защитный observer:
- DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob.
  🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный).
- Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на
  очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон
  в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob.

4.3 Своё имя/бренд + помесячная оплата:
- Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U).
- TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) /
  disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin.
- ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка
  free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended,
  кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются.
  Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование
  в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл).

4.4 Админ-тарифы/настройки:
- Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db.
- Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка
  ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён).

Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный
(+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений.
rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema
v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в
тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:41:33 +03:00

692 lines
22 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 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`);
}
// --- Клиентская Telegram-реклама: тарифы + настройки (задача 4.4) -----------
// Зеркало SMS-функций выше. Ступень = ₽ за показ по объёму (у нас — потолок/оценка).
/** Ступень тарифа Telegram-рекламы. */
export interface AdminTgTariff {
id: number;
min_qty: number;
price_rub: string;
}
/** Строка редактируемой ступени в PUT /api/admin/telegram/tariffs (без id — набор заменяется целиком). */
export interface AdminTgTariffRow {
min_qty: number;
price_rub: string;
}
/** Настройки платного имени (часть GET /api/admin/telegram/tariffs, а также PUT /api/admin/telegram/settings). */
export interface AdminTgSettings {
name_fee_rub_per_month: string;
name_debt_grace_days: number;
}
/** Ответ GET /api/admin/telegram/tariffs. */
export interface AdminTgTariffsResponse {
tariffs: AdminTgTariff[];
settings: AdminTgSettings;
}
/** Ответ PUT /api/admin/telegram/tariffs — заменённый набор ступеней. */
export interface AdminTgTariffsUpdateResponse {
tariffs: AdminTgTariff[];
}
/** GET /api/admin/telegram/tariffs — ступени тарифа + настройки платного имени. */
export async function fetchTgTariffs(): Promise<AdminTgTariffsResponse> {
const { data } = await apiClient.get<AdminTgTariffsResponse>('/api/admin/telegram/tariffs');
return data;
}
/** PUT /api/admin/telegram/tariffs — заменить весь набор ступеней. */
export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise<AdminTgTariffsUpdateResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminTgTariffsUpdateResponse>('/api/admin/telegram/tariffs', { rows });
return data;
}
/** PUT /api/admin/telegram/settings — плата за имя/мес и срок долга до отключения. */
export async function updateTgSettings(
nameFeeRubPerMonth: string,
nameDebtGraceDays: number,
): Promise<AdminTgSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminTgSettings>('/api/admin/telegram/settings', {
name_fee_rub_per_month: nameFeeRubPerMonth,
name_debt_grace_days: nameDebtGraceDays,
});
return data;
}