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).
This commit is contained in:
Дмитрий
2026-05-23 12:21:21 +03:00
parent 963379c3d9
commit 77e98afaa6
9 changed files with 1475 additions and 0 deletions
@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Services\Pd\PdErasureService;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
/**
* SaaS-admin: управление обращениями субъектов ПДн (152-ФЗ).
*
* Saas-уровневый endpoint (НЕ tenant-aware), под middleware('saas-admin').
* Production: middleware('auth:saas-admin') реализуется после Б-1 + DO-4.
*
* Маршруты:
* GET /api/admin/pd-subject-requests index
* POST /api/admin/pd-subject-requests store
* GET /api/admin/pd-subject-requests/{id} show
* POST /api/admin/pd-subject-requests/{id}/erase executeErasure
*/
class AdminPdSubjectRequestsController extends Controller
{
use ResolvesAdminUserId;
public function __construct(private readonly PdErasureService $erasureService) {}
/**
* GET /api/admin/pd-subject-requests
*
* Список обращений с пагинацией. Фильтры: status, request_type.
*/
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', '');
$requestType = (string) $request->query('request_type', '');
$limit = max(1, min(200, (int) $request->query('limit', '50')));
$offset = max(0, (int) $request->query('offset', '0'));
$query = DB::connection('pgsql_supplier')
->table('pd_subject_requests')
->orderByDesc('received_at')
->orderByDesc('id');
if ($status !== '') {
$query->where('status', $status);
}
if ($requestType !== '') {
$query->where('request_type', $requestType);
}
$total = (clone $query)->count('id');
$rows = $query->limit($limit)->offset($offset)->get();
return response()->json([
'data' => $rows->map(fn ($r) => $this->formatRow($r)),
'total' => $total,
'limit' => $limit,
'offset' => $offset,
]);
}
/**
* GET /api/admin/pd-subject-requests/{id}
*/
public function show(int $id): JsonResponse
{
$row = DB::connection('pgsql_supplier')
->table('pd_subject_requests')
->where('id', $id)
->first();
if ($row === null) {
return response()->json(['message' => 'Обращение не найдено.'], 404);
}
return response()->json(['data' => $this->formatRow($row)]);
}
/**
* POST /api/admin/pd-subject-requests
*
* Создать новое обращение субъекта. Deadline автоматически +30 дней
* через PostgreSQL-триггер trg_pd_subject_requests_deadline.
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'subject_email' => ['nullable', 'email', 'max:255'],
'subject_phone' => ['nullable', 'string', 'max:20'],
'subject_full_name' => ['nullable', 'string', 'max:255'],
'request_type' => ['required', Rule::in(['access', 'rectification', 'deletion', 'objection'])],
'description' => ['nullable', 'string', 'max:4096'],
'tenant_id' => ['nullable', 'integer', 'min:1'],
]);
// Минимум один идентификатор субъекта
if (empty($validated['subject_email']) && empty($validated['subject_phone'])) {
return response()->json([
'message' => 'Укажите email или телефон субъекта.',
'errors' => ['subject_email' => ['Необходимо email или телефон.']],
], 422);
}
$now = CarbonImmutable::now();
// NB: deadline_at заполняется триггером trg_pd_subject_requests_deadline
// (received_at + 30 дней). Передаём placeholder — триггер перезапишет.
$id = DB::connection('pgsql_supplier')
->table('pd_subject_requests')
->insertGetId([
'received_at' => $now,
'subject_email' => $validated['subject_email'] ?? null,
'subject_phone' => $validated['subject_phone'] ?? null,
'subject_full_name' => $validated['subject_full_name'] ?? null,
'request_type' => $validated['request_type'],
'description' => $validated['description'] ?? null,
'status' => 'received',
'tenant_id' => $validated['tenant_id'] ?? null,
'processing_restricted' => false,
// deadline_at: trigger перезапишет, но NOT NULL требует значения
'deadline_at' => $now->addDays(30),
]);
$row = DB::connection('pgsql_supplier')
->table('pd_subject_requests')
->where('id', $id)
->first();
return response()->json(['data' => $this->formatRow($row)], 201);
}
/**
* POST /api/admin/pd-subject-requests/{id}/erase
*
* Выполнить анонимизацию ПДн для обращения с request_type='deletion'.
* Возвращает counts анонимизированных записей.
*/
public function executeErasure(int $id, Request $request): JsonResponse
{
$row = DB::connection('pgsql_supplier')
->table('pd_subject_requests')
->where('id', $id)
->first();
if ($row === null) {
return response()->json(['message' => 'Обращение не найдено.'], 404);
}
if ($row->request_type !== 'deletion') {
return response()->json([
'message' => 'Анонимизация доступна только для обращений типа "deletion".',
], 422);
}
if ($row->status === 'completed') {
return response()->json([
'message' => 'Обращение уже выполнено.',
], 422);
}
if (empty($row->subject_email) && empty($row->subject_phone)) {
return response()->json([
'message' => 'В обращении не указан email или телефон субъекта.',
], 422);
}
$adminId = $this->resolveAdminUserId(
$request,
'pd-erasure-stub@system.local',
'PD Erasure System',
);
$counts = $this->erasureService->eraseSubject(
email: $row->subject_email ?: null,
phone: $row->subject_phone ?: null,
tenantId: $row->tenant_id !== null ? (int) $row->tenant_id : null,
actorAdminId: $adminId,
requestId: (string) $id,
);
return response()->json([
'message' => 'Анонимизация выполнена.',
'counts' => $counts,
]);
}
/**
* Форматировать строку pd_subject_requests в массив для API.
*
* @return array<string, mixed>
*/
private function formatRow(object $row): array
{
return [
'id' => (int) $row->id,
'received_at' => $row->received_at !== null
? CarbonImmutable::parse($row->received_at)->toIso8601String() : null,
'subject_email' => $row->subject_email,
'subject_phone' => $row->subject_phone,
'subject_full_name' => $row->subject_full_name,
'request_type' => $row->request_type,
'description' => $row->description,
'status' => $row->status,
'tenant_id' => $row->tenant_id !== null ? (int) $row->tenant_id : null,
'assigned_admin_id' => $row->assigned_admin_id !== null
? (int) $row->assigned_admin_id : null,
'response_text' => $row->response_text,
'deadline_at' => $row->deadline_at !== null
? CarbonImmutable::parse($row->deadline_at)->toIso8601String() : null,
'completed_at' => $row->completed_at !== null
? CarbonImmutable::parse($row->completed_at)->toIso8601String() : null,
'processing_restricted' => (bool) $row->processing_restricted,
];
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
/**
* Обращение субъекта ПДн (152-ФЗ).
*
* SaaS-уровневая таблица RLS не применяется. Доступ только из
* AdminPdSubjectRequestsController под saas-admin middleware.
*
* @property int $id
* @property string $received_at
* @property string|null $subject_email
* @property string|null $subject_phone
* @property string|null $subject_full_name
* @property string $request_type access|rectification|deletion|objection
* @property string|null $description
* @property string $status received|in_progress|completed|rejected
* @property int|null $tenant_id
* @property int|null $assigned_admin_id
* @property string|null $response_sent_at
* @property string|null $response_text
* @property string $deadline_at
* @property string|null $completed_at
* @property bool $processing_restricted
*/
class PdSubjectRequest extends Model
{
protected $table = 'pd_subject_requests';
public $timestamps = false;
/** @var list<string> */
protected $fillable = [
'received_at',
'subject_email',
'subject_phone',
'subject_full_name',
'request_type',
'description',
'status',
'tenant_id',
'assigned_admin_id',
'response_sent_at',
'response_text',
'deadline_at',
'completed_at',
'processing_restricted',
];
/** @var array<string, string> */
protected $casts = [
'received_at' => 'datetime',
'response_sent_at' => 'datetime',
'deadline_at' => 'datetime',
'completed_at' => 'datetime',
'processing_restricted' => 'boolean',
'tenant_id' => 'integer',
'assigned_admin_id' => 'integer',
];
/** Тенант, к которому относится обращение (nullable). */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* SaaS-админ, назначенный исполнителем.
*
* NB: модель SaasAdminUser не создана используем User как фиктивный базис.
* В реальном коде DB::table('saas_admin_users') напрямую в контроллере.
*/
// assignedAdmin: нет Eloquent-модели SaasAdminUser — читается напрямую через DB
}
+257
View File
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Services\Pd;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* Сервис анонимизации ПДн субъекта по 152-ФЗ (право на удаление, ст.21).
*
* Использует соединение pgsql_supplier (BYPASSRLS / crm_supplier_worker),
* чтобы читать и писать cross-tenant без RLS-ограничений.
*
* Реальные колонки схемы v8.19:
* users: email, first_name, last_name, phone
* supplier_leads: phone, raw_payload (JSONB) нет contact_email/contact_phone
* deals: phone, contact_name нет отдельного contact_email
* webhook_log: raw_payload (JSONB)
*/
class PdErasureService
{
private const DB = 'pgsql_supplier';
/**
* Анонимизировать все ПДн субъекта по email и/или телефону.
*
* @param string|null $email Email субъекта (один из двух обязателен)
* @param string|null $phone Телефон субъекта (один из двух обязателен)
* @param int|null $tenantId Ограничить поиск одним тенантом (null = все)
* @param int $actorAdminId ID saas_admin_users
* @param string|null $requestId ID pd_subject_requests для авто-закрытия
* @return array{users: int, leads: int, deals: int, webhook_log: int}
*
* @throws InvalidArgumentException если оба email и phone null
*/
public function eraseSubject(
?string $email,
?string $phone,
?int $tenantId,
int $actorAdminId,
?string $requestId = null,
): array {
if ($email === null && $phone === null) {
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
}
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0];
DB::connection(self::DB)->transaction(function () use (
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
): void {
$now = CarbonImmutable::now();
// ------------------------------------------------------------------
// 1. users
// ------------------------------------------------------------------
$userQuery = DB::connection(self::DB)->table('users');
$userQuery->where(function ($q) use ($email, $phone): void {
if ($email !== null) {
$q->orWhere('email', $email);
}
if ($phone !== null) {
$q->orWhere('phone', $phone);
}
});
if ($tenantId !== null) {
$userQuery->where('tenant_id', $tenantId);
}
$users = $userQuery->get(['id', 'tenant_id']);
foreach ($users as $user) {
$userId = (int) $user->id;
$userTenantId = (int) $user->tenant_id;
DB::connection(self::DB)->table('users')
->where('id', $userId)
->update([
'email' => 'erased-'.$userId.'@deleted.local',
'first_name' => 'Удалено',
'last_name' => null,
'phone' => '+7000'.str_pad((string) $userId, 7, '0', STR_PAD_LEFT),
'updated_at' => $now,
]);
$this->writePdLog(
tenantId: $userTenantId,
subjectType: 'user',
subjectId: $userId,
actorAdminId: $actorAdminId,
now: $now,
);
}
$counts['users'] = $users->count();
// ------------------------------------------------------------------
// 2. supplier_leads (phone + raw_payload JSONB)
// NB: нет contact_email / contact_phone — поиск только по phone
// ------------------------------------------------------------------
$leadQuery = DB::connection(self::DB)->table('supplier_leads');
if ($phone !== null) {
$leadQuery->where('phone', $phone);
} else {
// Только email — ищем в raw_payload JSONB
$leadQuery->whereRaw('raw_payload::text LIKE ?', ['%'.$email.'%']);
}
$leads = $leadQuery->get(['id']);
foreach ($leads as $lead) {
$leadId = (int) $lead->id;
DB::connection(self::DB)->table('supplier_leads')
->where('id', $leadId)
->update([
'phone' => '+7000XXXXXXX',
'raw_payload' => DB::connection(self::DB)->raw(
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
),
]);
$this->writePdLog(
tenantId: $tenantId,
subjectType: 'lead',
subjectId: $leadId,
actorAdminId: $actorAdminId,
now: $now,
);
}
$counts['leads'] = $leads->count();
// ------------------------------------------------------------------
// 3. deals (phone + contact_name)
// Deals партиционированы — UPDATE без WHERE на партиции через
// parent table работает начиная с PG 11+.
// ------------------------------------------------------------------
$dealQuery = DB::connection(self::DB)->table('deals');
$dealQuery->where(function ($q) use ($email, $phone): void {
if ($phone !== null) {
$q->orWhere('phone', $phone);
}
if ($email !== null) {
// Дополнительно: UTM/phones JSONB может хранить email, но в
// минимуме ищем только по phone. Email в deals не хранится
// в отдельной колонке.
}
});
if ($tenantId !== null) {
$dealQuery->where('tenant_id', $tenantId);
}
// Исключаем строки без совпадения по phone (когда phone=null — ничего не ищем)
if ($phone === null) {
// deals не имеет email-колонки, пропускаем
$dealQuery->whereRaw('FALSE');
}
$deals = $dealQuery->get(['id']);
foreach ($deals as $deal) {
$dealId = (int) $deal->id;
DB::connection(self::DB)->table('deals')
->where('id', $dealId)
->update([
'phone' => '+7000XXXXXXX',
'contact_name' => 'Удалено',
'updated_at' => $now,
]);
}
$counts['deals'] = $deals->count();
// ------------------------------------------------------------------
// 4. webhook_log (raw_payload JSONB text-search)
// ------------------------------------------------------------------
$wlQuery = DB::connection(self::DB)->table('webhook_log');
$conditions = [];
$bindings = [];
if ($email !== null) {
$conditions[] = 'raw_payload::text LIKE ?';
$bindings[] = '%'.$email.'%';
}
if ($phone !== null) {
$conditions[] = 'raw_payload::text LIKE ?';
$bindings[] = '%'.$phone.'%';
}
if (! empty($conditions)) {
$wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings);
}
if ($tenantId !== null) {
$wlQuery->where('tenant_id', $tenantId);
}
// Batched update: обрабатываем по 500 строк
$wlCount = 0;
$wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void {
$ids = $rows->pluck('id')->all();
DB::connection(self::DB)->table('webhook_log')
->whereIn('id', $ids)
->update([
'raw_payload' => DB::connection(self::DB)->raw(
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
),
]);
$wlCount += count($ids);
});
$counts['webhook_log'] = $wlCount;
// ------------------------------------------------------------------
// 5. Обновить pd_subject_requests если requestId передан
// ------------------------------------------------------------------
if ($requestId !== null) {
$summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, "
."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}";
DB::connection(self::DB)->table('pd_subject_requests')
->where('id', $requestId)
->update([
'status' => 'completed',
'completed_at' => $now,
'response_text' => $summary,
]);
}
});
return $counts;
}
/**
* Вставить запись в pd_processing_log через BYPASSRLS-соединение.
*/
private function writePdLog(
?int $tenantId,
string $subjectType,
int $subjectId,
int $actorAdminId,
CarbonImmutable $now,
): void {
DB::connection(self::DB)->table('pd_processing_log')->insert([
'tenant_id' => $tenantId,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'action' => 'deleted',
'purpose' => '152-FZ erasure',
'actor_admin_user_id' => $actorAdminId,
'created_at' => $now,
]);
}
}
+65
View File
@@ -494,3 +494,68 @@ export async function updateAdminSupplier(
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
View File
@@ -34,6 +34,7 @@ const navItems: NavItem[] = [
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
{ title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' },
{ title: 'Обращения ПДн (152-ФЗ)', icon: 'mdi-shield-account-outline', to: '/admin/pd-subject-requests' },
];
const route = useRoute();
+12
View File
@@ -295,6 +295,18 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Admin Supplier Projects',
},
},
{
path: '/admin/pd-subject-requests',
name: 'admin-pd-subject-requests',
component: () => import('../views/admin/AdminPdSubjectRequestsView.vue'),
meta: {
layout: 'admin',
title: 'Обращения ПДн',
requiresAuth: true,
devIndex: 32,
devLabel: 'Admin PD Requests',
},
},
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
{
path: '/403',
@@ -0,0 +1,498 @@
<script setup lang="ts">
/**
* Adminка SaaS → Обращения субъектов ПДн (152-ФЗ).
*
* Список обращений на удаление/доступ/исправление/возражение.
* Для request_type='deletion' — кнопка «Анонимизировать» (§ 1.5, дыра #4).
*
* API: GET/POST /api/admin/pd-subject-requests, POST /{id}/erase
*/
import { onMounted, ref, reactive, computed } from 'vue';
import * as adminApi from '../../api/admin';
import type { PdSubjectRequest, CreatePdRequestPayload } from '../../api/admin';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const rows = ref<PdSubjectRequest[]>([]);
const total = ref(0);
const loading = ref(false);
const fetchError = ref(false);
const filterStatus = ref('');
const filterType = ref('');
// Dialog: create
const createDialog = ref(false);
const createLoading = ref(false);
const createError = ref('');
const createForm = reactive<CreatePdRequestPayload>({
subject_email: '',
subject_phone: '',
subject_full_name: '',
request_type: 'deletion',
description: '',
tenant_id: null,
});
// Dialog: erase confirm
const eraseDialog = ref(false);
const eraseLoading = ref(false);
const eraseTarget = ref<PdSubjectRequest | null>(null);
const eraseResult = ref<{ users: number; leads: number; deals: number; webhook_log: number } | null>(null);
// ---------------------------------------------------------------------------
// Load data
// ---------------------------------------------------------------------------
async function loadRows(): Promise<void> {
loading.value = true;
fetchError.value = false;
try {
const res = await adminApi.listPdSubjectRequests({
status: filterStatus.value || undefined,
request_type: filterType.value || undefined,
limit: 100,
offset: 0,
});
rows.value = res.data;
total.value = res.total;
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadRows);
// ---------------------------------------------------------------------------
// Create request
// ---------------------------------------------------------------------------
async function submitCreate(): Promise<void> {
createError.value = '';
if (!createForm.subject_email && !createForm.subject_phone) {
createError.value = 'Укажите email или телефон субъекта.';
return;
}
createLoading.value = true;
try {
await adminApi.createPdSubjectRequest({
subject_email: createForm.subject_email || undefined,
subject_phone: createForm.subject_phone || undefined,
subject_full_name: createForm.subject_full_name || undefined,
request_type: createForm.request_type,
description: createForm.description || undefined,
tenant_id: createForm.tenant_id ?? undefined,
});
createDialog.value = false;
resetCreateForm();
await loadRows();
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
createError.value = err?.response?.data?.message ?? 'Ошибка при создании обращения.';
} finally {
createLoading.value = false;
}
}
function resetCreateForm(): void {
createForm.subject_email = '';
createForm.subject_phone = '';
createForm.subject_full_name = '';
createForm.request_type = 'deletion';
createForm.description = '';
createForm.tenant_id = null;
createError.value = '';
}
// ---------------------------------------------------------------------------
// Erase
// ---------------------------------------------------------------------------
function openErase(row: PdSubjectRequest): void {
eraseTarget.value = row;
eraseResult.value = null;
eraseDialog.value = true;
}
async function confirmErase(): Promise<void> {
if (!eraseTarget.value) return;
eraseLoading.value = true;
try {
const res = await adminApi.executePdErasure(eraseTarget.value.id);
eraseResult.value = res.counts;
// Update row status in list
const idx = rows.value.findIndex((r) => r.id === eraseTarget.value?.id);
if (idx !== -1) {
rows.value[idx] = { ...rows.value[idx], status: 'completed' };
}
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
alert(err?.response?.data?.message ?? 'Ошибка анонимизации.');
eraseDialog.value = false;
} finally {
eraseLoading.value = false;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const statusLabels: Record<string, { label: string; color: string }> = {
received: { label: 'Получено', color: 'info' },
in_progress: { label: 'В работе', color: 'warning' },
completed: { label: 'Выполнено', color: 'success' },
rejected: { label: 'Отклонено', color: 'error' },
};
function statusInfo(s: string) {
return statusLabels[s] ?? { label: s, color: 'default' };
}
const typeLabels: Record<string, string> = {
access: 'Доступ',
rectification: 'Исправление',
deletion: 'Удаление',
objection: 'Возражение',
};
function typeLabel(t: string): string {
return typeLabels[t] ?? t;
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
const headers = [
{ title: 'ID', key: 'id', width: '60px' },
{ title: 'Получено', key: 'received_at', width: '140px' },
{ title: 'Email / тел.', key: 'contact', sortable: false },
{ title: 'Тип', key: 'request_type', width: '110px' },
{ title: 'Статус', key: 'status', width: '120px' },
{ title: 'Дедлайн', key: 'deadline_at', width: '140px' },
{ title: 'Действия', key: 'actions', sortable: false, width: '140px', align: 'end' as const },
];
const filteredRows = computed(() => rows.value);
defineExpose({ rows, loading, fetchError, loadRows });
</script>
<template>
<v-container fluid class="admin-pd pa-6">
<!-- Page head -->
<header class="page-head mb-4 d-flex justify-space-between align-start flex-wrap ga-3">
<div>
<h1 class="text-h4 page-title">Обращения субъектов ПДн</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Обращения на доступ, исправление, удаление и возражение (152-ФЗ).
Срок ответа 30 дней.
</p>
</div>
<div class="d-flex ga-2">
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadRows"
>
Обновить
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-plus"
data-testid="create-btn"
@click="createDialog = true"
>
Новый запрос
</v-btn>
</div>
</header>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="fetch-error-alert"
>
Не удалось загрузить обращения. Попробуйте обновить.
</v-alert>
<!-- Filters -->
<v-card variant="outlined" class="pa-3 mb-4">
<v-row dense>
<v-col cols="12" sm="4">
<v-select
v-model="filterStatus"
label="Статус"
:items="[
{ title: 'Все статусы', value: '' },
{ title: 'Получено', value: 'received' },
{ title: 'В работе', value: 'in_progress' },
{ title: 'Выполнено', value: 'completed' },
{ title: 'Отклонено', value: 'rejected' },
]"
density="compact"
variant="outlined"
hide-details
@update:model-value="loadRows"
/>
</v-col>
<v-col cols="12" sm="4">
<v-select
v-model="filterType"
label="Тип обращения"
:items="[
{ title: 'Все типы', value: '' },
{ title: 'Доступ', value: 'access' },
{ title: 'Исправление', value: 'rectification' },
{ title: 'Удаление', value: 'deletion' },
{ title: 'Возражение', value: 'objection' },
]"
density="compact"
variant="outlined"
hide-details
@update:model-value="loadRows"
/>
</v-col>
<v-col cols="12" sm="4" class="d-flex align-center">
<span class="text-body-2 text-medium-emphasis">Всего: {{ total }}</span>
</v-col>
</v-row>
</v-card>
<!-- Table -->
<v-card variant="outlined">
<v-data-table
:headers="headers"
:items="filteredRows"
:loading="loading"
item-value="id"
density="compact"
no-data-text="Обращений нет"
data-testid="pd-requests-table"
>
<template v-slot:[`item.received_at`]="{ item }">
<span class="text-caption font-mono">{{ formatDate(item.received_at) }}</span>
</template>
<template v-slot:[`item.contact`]="{ item }">
<div>
<span v-if="item.subject_email" class="d-block text-body-2">{{ item.subject_email }}</span>
<span v-if="item.subject_phone" class="d-block text-caption text-medium-emphasis">
{{ item.subject_phone }}
</span>
<span v-if="item.subject_full_name" class="d-block text-caption text-medium-emphasis">
{{ item.subject_full_name }}
</span>
</div>
</template>
<template v-slot:[`item.request_type`]="{ item }">
<v-chip
:color="item.request_type === 'deletion' ? 'error' : 'default'"
size="x-small"
variant="tonal"
>
{{ typeLabel(item.request_type) }}
</v-chip>
</template>
<template v-slot:[`item.status`]="{ item }">
<v-chip
:color="statusInfo(item.status).color"
size="x-small"
variant="tonal"
>
{{ statusInfo(item.status).label }}
</v-chip>
</template>
<template v-slot:[`item.deadline_at`]="{ item }">
<span
class="text-caption"
:class="item.status !== 'completed' && new Date(item.deadline_at) < new Date() ? 'text-error' : ''"
>
{{ formatDate(item.deadline_at) }}
</span>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn
v-if="item.request_type === 'deletion' && item.status !== 'completed'"
color="error"
size="x-small"
variant="tonal"
prepend-icon="mdi-delete-forever"
:data-testid="`erase-btn-${item.id}`"
@click="openErase(item)"
>
Анонимизировать
</v-btn>
<v-chip
v-else-if="item.status === 'completed'"
color="success"
size="x-small"
variant="text"
>
Выполнено
</v-chip>
</template>
</v-data-table>
</v-card>
<!-- Dialog: create request -->
<v-dialog v-model="createDialog" max-width="520" data-testid="create-dialog">
<v-card>
<v-card-title class="text-h6 pa-4 pb-2">Новое обращение субъекта ПДн</v-card-title>
<v-card-text class="pa-4 pt-0">
<v-alert
v-if="createError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
>
{{ createError }}
</v-alert>
<v-select
v-model="createForm.request_type"
label="Тип обращения *"
:items="[
{ title: 'Доступ к данным', value: 'access' },
{ title: 'Исправление данных', value: 'rectification' },
{ title: 'Удаление данных', value: 'deletion' },
{ title: 'Возражение', value: 'objection' },
]"
density="compact"
variant="outlined"
class="mb-3"
data-testid="form-request-type"
/>
<v-text-field
v-model="createForm.subject_email"
label="Email субъекта"
type="email"
density="compact"
variant="outlined"
class="mb-2"
data-testid="form-email"
/>
<v-text-field
v-model="createForm.subject_phone"
label="Телефон субъекта"
density="compact"
variant="outlined"
class="mb-2"
data-testid="form-phone"
/>
<v-text-field
v-model="createForm.subject_full_name"
label="ФИО субъекта"
density="compact"
variant="outlined"
class="mb-2"
/>
<v-text-field
v-model.number="createForm.tenant_id"
label="ID тенанта (необязательно)"
type="number"
density="compact"
variant="outlined"
class="mb-2"
/>
<v-textarea
v-model="createForm.description"
label="Описание"
density="compact"
variant="outlined"
rows="3"
/>
</v-card-text>
<v-card-actions class="pa-4 pt-0 justify-end">
<v-btn variant="text" @click="createDialog = false; resetCreateForm()">Отмена</v-btn>
<v-btn
color="primary"
:loading="createLoading"
data-testid="submit-create-btn"
@click="submitCreate"
>
Создать
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog: erase confirm -->
<v-dialog v-model="eraseDialog" max-width="480" data-testid="erase-dialog">
<v-card>
<v-card-title class="text-h6 pa-4 pb-2 text-error">
Анонимизировать данные субъекта
</v-card-title>
<v-card-text class="pa-4 pt-0">
<template v-if="!eraseResult">
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Операция необратима. Данные будут заменены плейсхолдерами.
</v-alert>
<p class="text-body-2 mb-1">
<strong>Email:</strong> {{ eraseTarget?.subject_email ?? '—' }}
</p>
<p class="text-body-2 mb-1">
<strong>Телефон:</strong> {{ eraseTarget?.subject_phone ?? '—' }}
</p>
<p class="text-body-2">
<strong>Тенант:</strong> {{ eraseTarget?.tenant_id ?? 'все' }}
</p>
</template>
<template v-else>
<v-alert type="success" variant="tonal" density="compact" class="mb-3">
Анонимизация выполнена.
</v-alert>
<p class="text-body-2 mb-1">Пользователей: <strong>{{ eraseResult.users }}</strong></p>
<p class="text-body-2 mb-1">Лидов поставщика: <strong>{{ eraseResult.leads }}</strong></p>
<p class="text-body-2 mb-1">Сделок: <strong>{{ eraseResult.deals }}</strong></p>
<p class="text-body-2">Webhook-логов: <strong>{{ eraseResult.webhook_log }}</strong></p>
</template>
</v-card-text>
<v-card-actions class="pa-4 pt-0 justify-end">
<v-btn
variant="text"
@click="eraseDialog = false"
>
{{ eraseResult ? 'Закрыть' : 'Отмена' }}
</v-btn>
<v-btn
v-if="!eraseResult"
color="error"
:loading="eraseLoading"
data-testid="confirm-erase-btn"
@click="confirmErase"
>
Подтвердить удаление
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.admin-pd {
max-width: 1200px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
</style>
+10
View File
@@ -162,6 +162,16 @@ Route::middleware('saas-admin')->group(function () {
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
// 152-ФЗ: обращения субъектов ПДн + анонимизация (дыра #4).
Route::prefix('/api/admin/pd-subject-requests')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@index');
Route::post('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@store');
Route::get('/{id}', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@show')
->where('id', '[0-9]+');
Route::post('/{id}/erase', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@executeErasure')
->where('id', '[0-9]+');
});
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
@@ -0,0 +1,329 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Services\Pd\PdErasureService;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Создать stub saas_admin_users и вернуть его id. */
function pdStubAdminUser(string $email = 'pd-test-stub@system.local'): int
{
$existing = DB::table('saas_admin_users')->where('email', $email)->value('id');
if ($existing !== null) {
return (int) $existing;
}
return (int) DB::table('saas_admin_users')->insertGetId([
'email' => $email,
'full_name' => 'PD Test Stub',
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => false,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
}
/** Создать тенант и вернуть его. */
function pdCreateTenant(): Tenant
{
return Tenant::factory()->create([
'subdomain' => 'pd-test-'.uniqid(),
'organization_name' => 'PD Test Org',
'contact_email' => 'pd-tenant@test.local',
'status' => 'active',
]);
}
/** Вставить запись pd_subject_requests напрямую и вернуть id. */
function pdInsertRequest(array $attrs = []): int
{
$defaults = [
'received_at' => now(),
'subject_email' => 'subject@example.com',
'subject_phone' => null,
'subject_full_name' => 'Test Subject',
'request_type' => 'deletion',
'description' => 'Test description',
'status' => 'received',
'tenant_id' => null,
'processing_restricted' => false,
// deadline_at заполняется триггером, но NOT NULL — вставим вручную
'deadline_at' => now()->addDays(30),
];
return (int) DB::connection('pgsql_supplier')
->table('pd_subject_requests')
->insertGetId(array_merge($defaults, $attrs));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
it('index returns paginated list of pd_subject_requests', function (): void {
$this->actingAs(User::factory()->create());
// Вставим 2 записи
pdInsertRequest(['request_type' => 'deletion']);
pdInsertRequest(['request_type' => 'access', 'status' => 'completed']);
$response = $this->getJson('/api/admin/pd-subject-requests');
$response->assertOk();
expect($response->json('total'))->toBeGreaterThanOrEqual(2);
expect($response->json('data'))->toBeArray();
$first = $response->json('data.0');
expect($first)->toHaveKeys(['id', 'received_at', 'request_type', 'status', 'deadline_at']);
});
it('index filters by status', function (): void {
$this->actingAs(User::factory()->create());
pdInsertRequest(['status' => 'received']);
pdInsertRequest(['status' => 'completed', 'request_type' => 'access']);
$response = $this->getJson('/api/admin/pd-subject-requests?status=received');
$response->assertOk();
foreach ($response->json('data') as $row) {
expect($row['status'])->toBe('received');
}
});
it('store creates pd_subject_request with deadline_at ~+30 days from received_at', function (): void {
$this->actingAs(User::factory()->create());
$response = $this->postJson('/api/admin/pd-subject-requests', [
'subject_email' => 'newsubject@example.com',
'request_type' => 'deletion',
'description' => 'Please delete my data.',
]);
$response->assertCreated();
$data = $response->json('data');
expect($data['subject_email'])->toBe('newsubject@example.com');
expect($data['request_type'])->toBe('deletion');
expect($data['status'])->toBe('received');
// deadline_at должен быть ~30 дней вперёд (с погрешностью ±2 дня на тест-лаги)
$deadline = CarbonImmutable::parse($data['deadline_at']);
$received = CarbonImmutable::parse($data['received_at']);
// diffInDays: абсолютное значение (порядок параметров не важен с abs)
$diff = abs($deadline->diffInDays($received));
expect($diff)->toBeGreaterThanOrEqual(29)->toBeLessThanOrEqual(31);
});
it('store validates: at least email or phone required', function (): void {
$this->actingAs(User::factory()->create());
$this->postJson('/api/admin/pd-subject-requests', [
'request_type' => 'deletion',
])->assertStatus(422);
});
it('store validates: request_type must be valid', function (): void {
$this->actingAs(User::factory()->create());
$this->postJson('/api/admin/pd-subject-requests', [
'subject_email' => 'x@y.com',
'request_type' => 'invalid_type',
])->assertStatus(422);
});
it('executeErasure anonymises user email first_name phone and writes pd_processing_log', function (): void {
$this->actingAs(User::factory()->create());
// Используем pgsql_supplier для всех вставок, чтобы FK-проверки работали
// в рамках одного соединения (DatabaseTransactions оборачивает default pgsql,
// но pgsql_supplier видит только committed данные default-соединения).
$stubEmail = 'pd-user-stub-'.uniqid().'@system.local';
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
'email' => $stubEmail,
'full_name' => 'User Test Stub',
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => false,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
// Создаём тенант через pgsql_supplier (тот же физ. сервер/БД)
$tenantId = (int) DB::connection('pgsql_supplier')->table('tenants')->insertGetId([
'subdomain' => 'pd-user-test-'.uniqid(),
'organization_name' => 'PD User Test',
'contact_email' => 'pd-u@test.local',
'status' => 'active',
'webhook_token' => bin2hex(random_bytes(16)),
'balance_rub' => '0.00',
'balance_leads' => 0,
'is_trial' => false,
'chargeback_unrecovered_rub' => '0.00',
'created_at' => now(),
]);
// Создаём user с email/phone субъекта
$victimEmail = 'victim-'.uniqid().'@example.com';
$victimPhone = '+79991234567';
$userId = (int) DB::connection('pgsql_supplier')->table('users')->insertGetId([
'tenant_id' => $tenantId,
'email' => $victimEmail,
'password_hash' => '$2y$04$test',
'first_name' => 'Иван',
'last_name' => 'Иванов',
'phone' => $victimPhone,
'is_active' => true,
'created_at' => now(),
]);
$requestId = pdInsertRequest([
'subject_email' => $victimEmail,
'subject_phone' => $victimPhone,
'tenant_id' => $tenantId,
'request_type' => 'deletion',
'status' => 'received',
]);
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
'admin_user_id' => $adminId,
]);
$response->assertOk();
expect($response->json('counts.users'))->toBe(1);
// Проверяем анонимизацию user
$user = DB::connection('pgsql_supplier')->table('users')->where('id', $userId)->first();
expect($user->email)->toContain('erased-');
expect($user->first_name)->toBe('Удалено');
expect($user->phone)->toContain('+7000');
// pd_processing_log должен содержать запись
$log = DB::connection('pgsql_supplier')
->table('pd_processing_log')
->where('subject_id', $userId)
->where('subject_type', 'user')
->where('action', 'deleted')
->where('actor_admin_user_id', $adminId)
->first();
expect($log)->not->toBeNull();
expect($log->purpose)->toBe('152-FZ erasure');
});
it('executeErasure anonymises supplier_lead phone and raw_payload', function (): void {
$this->actingAs(User::factory()->create());
// Создаём stub admin через pgsql_supplier, чтобы FK pd_processing_log работал
// независимо от DatabaseTransactions-транзакции default-соединения.
$stubEmail = 'pd-lead-stub-'.uniqid().'@system.local';
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
'email' => $stubEmail,
'full_name' => 'Lead Test Stub',
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => false,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
$victimPhone = '+79887654321';
$leadId = (int) DB::connection('pgsql_supplier')->table('supplier_leads')->insertGetId([
'platform' => 'B1',
'raw_payload' => json_encode(['phone' => $victimPhone, 'name' => 'Жертва']),
'phone' => $victimPhone,
'received_at' => now(),
'source' => 'webhook',
]);
$requestId = pdInsertRequest([
'subject_phone' => $victimPhone,
'request_type' => 'deletion',
'status' => 'received',
]);
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
'admin_user_id' => $adminId,
]);
$response->assertOk();
expect($response->json('counts.leads'))->toBeGreaterThanOrEqual(1);
$lead = DB::connection('pgsql_supplier')->table('supplier_leads')->where('id', $leadId)->first();
expect($lead->phone)->toBe('+7000XXXXXXX');
$payload = json_decode($lead->raw_payload, true);
expect($payload['erased'])->toBe(true);
});
it('executeErasure marks pd_subject_request as completed', function (): void {
$this->actingAs(User::factory()->create());
$adminId = pdStubAdminUser();
$requestId = pdInsertRequest([
'subject_email' => 'mark-completed-'.uniqid().'@example.com',
'request_type' => 'deletion',
'status' => 'received',
]);
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
'admin_user_id' => $adminId,
])->assertOk();
$row = DB::connection('pgsql_supplier')
->table('pd_subject_requests')
->where('id', $requestId)
->first();
expect($row->status)->toBe('completed');
expect($row->completed_at)->not->toBeNull();
expect($row->response_text)->toContain('users=');
});
it('executeErasure rejects non-deletion request_type with 422', function (): void {
$this->actingAs(User::factory()->create());
$requestId = pdInsertRequest([
'subject_email' => 'access-request@example.com',
'request_type' => 'access',
'status' => 'received',
]);
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
->assertStatus(422);
});
it('executeErasure rejects already completed request with 422', function (): void {
$this->actingAs(User::factory()->create());
$requestId = pdInsertRequest([
'subject_email' => 'already-done-'.uniqid().'@example.com',
'request_type' => 'deletion',
'status' => 'completed',
]);
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
->assertStatus(422);
});
it('saas-admin middleware allows request in testing env', function (): void {
// EnsureSaasAdmin в testing-окружении пропускает всех без проверки.
$response = $this->getJson('/api/admin/pd-subject-requests');
$response->assertOk();
});
it('PdErasureService throws InvalidArgumentException when both email and phone are null', function (): void {
$service = app(PdErasureService::class);
expect(fn () => $service->eraseSubject(null, null, null, 1, null))
->toThrow(InvalidArgumentException::class);
});