Files
portal/app/app/Http/Controllers/Api/AdminPdSubjectRequestsController.php
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

223 lines
8.5 KiB
PHP
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.
<?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,
];
}
}