Files
portal/app/resources/js/views/admin/AdminPdSubjectRequestsView.vue
T
Дмитрий 1f260c0b97 fix/обзвон: честный отчёт о стирании доходит до оператора - З-2.5, второй заход
Приёмка вскрыла, что первый заход воспроизвёл ту же беду на другом основании.

1. Невод по тексту не мог найти НИЧЕГО по построению.
   `obzvon_materialy_klienta.transcript` не пишет никто: расшифровщик живёт в
   З-4.2, волна 4. Значит сводка честно говорила "материалы клиента=0", пока
   голос человека лежал на диске 30 дней. Теперь портал считает отдельным числом
   записи, которые проверить НЕЧЕМ - звук жив, расшифровки нет и не было, - и
   говорит это тревожной строкой.
   🔴 Условие уточнено против предложенного: добавлено transcript_deleted_at IS
   NULL. Без него строка, у которой расшифровку стёрли мы сами, а файл убрать не
   смогли, попадала бы в оба числа сразу. Доказано надрезом.

2. Изоляция клиентов не сторожилась ничем. Соединение обходит защиту строк, значит
   отбор по клиенту в коде - ЕДИНСТВЕННЫЙ замок. Сняв его, приёмщик оставил все
   13 сторожей зелёными. Заведены сторожа на все три таблицы.

3. Своя находка того же класса: читатель флага сверял телефон точными написаниями
   с колонкой, которую заполняет ЧЕЛОВЕК руками. На записи "8 (900) 123-45-67"
   он молча отвечал "звонить можно" тому, кто потребовал прекратить обработку.
   Сверка переведена на хвост из десяти цифр - и там, и в переходнике.

4. Экран админки показывал зелёную галочку "выполнено" и пустое поле
   "Webhook-логов", а про обзвон и про нестёртые файлы молчал. Это видимая
   половина той же неправды: тот, кто жмёт кнопку, и есть тот, кто обязан пойти
   проверить руками. Экран называет обзвон поимённо, при нестёртых файлах и
   непроверяемых записях галочки нет вовсе - вместо неё тревога. Мёртвое поле
   убрано вместе с полем в типе ответа.

Сторожа: 13 -> 19 на сервере, плюс 5 экранных. Каждый показан красным семью
надрезами по коду.

Отчёт: docs/superpowers/priyomka/stroyka-2/z-2-5-otchyot-2026-08-05.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:26:49 +03:00

563 lines
25 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);
// UI-аудит: window.alert → v-snackbar.
const errToastOpen = ref(false);
const errToastText = ref('');
const eraseTarget = ref<PdSubjectRequest | null>(null);
const eraseResult = ref<adminApi.EraseSubjectResult['counts'] | null>(null);
/**
* Есть ли то, ради чего оператор обязан пойти и проверить руками.
*
* 🔴 Два случая, и оба — не «мелкая деталь итога»:
* • `fayly_ostalis` — файл с голосом человека ЖИВ, стереть его не смогли;
* • `materialy_ne_proverit` — записи клиента, где голос мог бы лежать, а
* проверить их нечем: расшифровки у них нет, а другой дороги до человека в
* той таблице не существует.
* Показать это строчкой в ряду чисел значило бы утопить предупреждение в
* зелёной галочке — то же самое враньё, что отметка «выполнено» в базе.
*/
const trebuetRukiFayly = computed<number>(() => eraseResult.value?.obzvon.fayly_ostalis ?? 0);
const trebuetRukiZapisi = computed<number>(() => eraseResult.value?.obzvon.materialy_ne_proverit ?? 0);
const trebuetRuki = computed<boolean>(() => trebuetRukiFayly.value > 0 || trebuetRukiZapisi.value > 0);
// ---------------------------------------------------------------------------
// 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 } } };
errToastText.value = err?.response?.data?.message ?? 'Ошибка анонимизации.';
errToastOpen.value = true;
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',
timeZone: 'Europe/Moscow',
});
}
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 #[`item.received_at`]="{ item }">
<span class="text-caption font-mono">{{ formatDate(item.received_at) }}</span>
</template>
<template #[`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 #[`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 #[`item.status`]="{ item }">
<v-chip :color="statusInfo(item.status).color" size="x-small" variant="tonal">
{{ statusInfo(item.status).label }}
</v-chip>
</template>
<template #[`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 #[`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>
<!--
🔴 Зелёная галочка стоит ТОЛЬКО когда проверять руками
нечего. Иначе оператор увидит тревогу: он и есть тот,
кто обязан пойти и посмотреть. «Выполнено» при живом
голосе то же враньё, что отметка completed в базе.
-->
<v-alert
v-if="!trebuetRuki"
type="success"
variant="tonal"
density="compact"
class="mb-3"
data-testid="erase-ok"
>
Анонимизация выполнена.
</v-alert>
<v-alert
v-else
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="erase-trebuet-ruki"
>
<div class="font-weight-bold mb-1">Выполнено не полностью нужна ручная проверка</div>
<div v-if="trebuetRukiFayly > 0" data-testid="erase-fayly-ostalis">
Не удалось стереть файлов: <strong>{{ trebuetRukiFayly }}</strong
>. Записи с голосом человека остались на диске, и строки НЕ помечены стёртыми.
</div>
<div v-if="trebuetRukiZapisi > 0" data-testid="erase-ne-proverit">
Проверить было нечем: <strong>{{ trebuetRukiZapisi }}</strong> записей клиента с живым
звуком и без расшифровки. Поиск в них идёт по тексту расшифровки, а её у этих записей
нет.
</div>
</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-3">
Сделок: <strong>{{ eraseResult.deals }}</strong>
</p>
<!--
Обзвон назван поимённо, а не свёрнут в одно число:
голос, слова и телефон человека разные вещи, и
«список набора» отвечает на отдельный вопрос
«позвонят ли ему снова».
-->
<div class="text-body-2 font-weight-medium mb-1">Обзвон</div>
<p class="text-body-2 mb-1" data-testid="erase-obzvon-zvonki">
Звонков: <strong>{{ eraseResult.obzvon.zvonki }}</strong> звук
{{ eraseResult.obzvon.zvonki_zvuk }}, расшифровок
{{ eraseResult.obzvon.zvonki_tekst }}, телефонов
{{ eraseResult.obzvon.zvonki_telefon }}
</p>
<p class="text-body-2 mb-1" data-testid="erase-obzvon-spisok">
Номеров в списке набора: <strong>{{ eraseResult.obzvon.spisok_nabora }}</strong>
</p>
<p class="text-body-2" data-testid="erase-obzvon-materialy">
Записей клиента: <strong>{{ eraseResult.obzvon.materialy }}</strong> звук
{{ eraseResult.obzvon.materialy_zvuk }}, расшифровок
{{ eraseResult.obzvon.materialy_tekst }}
</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-snackbar v-model="errToastOpen" :timeout="4000" color="error" location="bottom right">
{{ errToastText }}
</v-snackbar>
</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>