Files
portal/app/resources/js/api/admin.ts
T
Дмитрий 77e98afaa6 feat(pd): 152-ФЗ право на удаление — минимум (hole #4)
Закрывает дыру #4 аудита журналирования. Объём по выбору заказчика — МИНИМУМ:
 Админ-API + кнопка в админке для удаления ПДн субъекта
 Сервис анонимизации (users + supplier_leads + deals + webhook_log)
 Журнал факта удаления в pd_processing_log
 БЕЗ формы самообслуживания на стороне субъекта
 БЕЗ email-подтверждения
 БЕЗ 30-дневного SLA (trigger deadline_at уже в схеме)

Что добавлено:
* Eloquent-модель `App\Models\PdSubjectRequest` (таблица уже была в схеме)
* Сервис `App\Services\Pd\PdErasureService::eraseSubject()`:
  - cross-tenant через pgsql_supplier (BYPASSRLS)
  - транзакционно (rollback при ошибке)
  - users: email→erased-{id}@deleted.local, first_name→Удалено, last_name→null,
    phone→+7000{id}
  - supplier_leads: phone→+7000XXXXXXX, raw_payload→{erased:true}
  - deals: phone→+7000XXXXXXX, contact_name→Удалено (только если есть phone)
  - webhook_log: batched UPDATE по 500, raw_payload→{erased,erased_at}
  - pd_processing_log запись action=deleted за каждого user/lead с
    actor_admin_user_id (hash-chain audit_chain_hash триггером сам подписывает)
  - При requestId — pd_subject_requests SET status=completed, completed_at,
    response_text счёт
* Контроллер `AdminPdSubjectRequestsController`: index/show/store/executeErasure
* Маршруты под middleware(saas-admin): GET/POST /api/admin/pd-subject-requests,
  GET /{id}, POST /{id}/erase
* Vue: `AdminPdSubjectRequestsView` (Quiet Luxury, таблица + диалог создания +
  кнопка Анонимизировать для request_type=deletion); ESLint требует
  v-slot:[`item.X`]= вместо #item.X для динамических slot-имён с точкой
* Пункт меню в AdminLayout.vue + route /admin/pd-subject-requests

NB: реальная схема — users.first_name/last_name/phone/email; supplier_leads
имеет только phone (нет contact_*); deals имеет phone+contact_name (нет
contact_email); webhook_log JSONB. PdErasureService адаптирован под факт.

Тесты: 12/12 passed (63 assertions, ~2.6s) — index pagination, store +
deadline trigger (+30 дней), eraseSubject анонимизация user/lead/deal/log,
pd_processing_log запись, request status→completed, отклонение
не-deletion типов, gate saas-admin, InvalidArgumentException.

Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#4).
2026-05-23 12:21:21 +03:00

562 lines
16 KiB
TypeScript

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;
}
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;
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 UpdateSystemSettingPayload {
value: string;
reason: string; // ≥30 chars
admin_user_id: number; // на prod удалится
}
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 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;
}