Files
portal/app/resources/js/views/admin/AdminPdSubjectRequestsView.vue
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

499 lines
20 KiB
Vue
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.
<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>