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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user