77e98afaa6
Закрывает дыру #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).
223 lines
8.5 KiB
PHP
223 lines
8.5 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|