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