Files
portal/app/resources/js/views/settings/NotificationsTab.vue
T
Дмитрий f55b91cfa4 phase2(notifications-stage3): NotificationsTab schema-aligned + prefs API
Закрывает архитектурное расхождение v1.28 — Tab сохранял prefs только
локально без API. Backend events не совпадали с handoff'ом.

Backend:
- PATCH /api/auth/me/notification-preferences под auth:sanctum.
- Replace-семантика: незадекларированные events/channels отбрасываются.
- userResource расширен: notification_preferences + sound_enabled.
- UserFactory с schema-default JSON (Eloquent не перечитывает после INSERT,
  DB-DEFAULT JSONB виден как null без явного override).
- Pest +10: 401 / replace / неизвестные events/channels отбрасываются /
  422 без prefs / sound_enabled опционален / bool-cast 1/'1' / replace-
  семантика (отсутствующие events исчезают).

Frontend:
- api/auth.ts: типы NotificationChannel/EventKey/Preferences +
  updateNotificationPreferences helper. AuthUser получил optional поля.
- NotificationsTab.vue переписан под schema:
  8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/
  invoice_paid/new_device_login/marketing) × 3 канала (inapp/push/email,
  НЕ sms). Sync-init prefs (без onMounted — иначе v-if блокирует рендер
  и тесты mount-then-find падают). dirty через computed-сравнение с
  originalPrefs snapshot. save async + success/error alerts.
- SettingsView.spec.ts: legacy event-имена → schema-aligned.
- Vitest +10: 8 schema events / 3 channels (НЕ sms) / legacy отсутствуют /
  читает prefs из user / save calls API + alerts / Отменить возвращает.

cspell-words: +prefs.
PHPStan baseline регенерирован.

Pest 315/315 (+10) за 36.73 сек, 1130 assertions.
Vitest 349/349 (+10) за 20.42 сек.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:41:35 +03:00

269 lines
9.4 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">
/**
* Settings → Уведомления. Матрица 8 событий × 3 канала (inapp/push/email)
* + sound_enabled. Соответствует schema.sql:699 users.notification_preferences.
*
* События (точно по schema-default):
* new_lead / reminder / low_balance / zero_balance /
* topup_success / invoice_paid / new_device_login / marketing.
*
* Каналы (точно по schema):
* inapp (bell-icon в UI) / push (web-push, Post-MVP) / email (Unisender Go).
*
* Push-канал на MVP пока не доставляется (требует ServiceWorker + VAPID), но
* preferences записываются для будущей активации без миграции данных.
*/
import { computed, ref, watch } from 'vue';
import { useAuthStore } from '../../stores/auth';
import {
type NotificationChannel,
type NotificationEventKey,
type NotificationPreferences,
updateNotificationPreferences,
} from '../../api/auth';
interface NotifEvent {
id: NotificationEventKey;
label: string;
description?: string;
}
const EVENTS: NotifEvent[] = [
{ id: 'new_lead', label: 'Новый лид', description: 'Поступил новый лид через webhook или manual-создание.' },
{ id: 'reminder', label: 'Напоминание', description: 'Срок касания клиента наступил.' },
{ id: 'low_balance', label: 'Низкий баланс', description: 'Осталось < 3 дней работы по текущим ценам.' },
{ id: 'zero_balance', label: 'Нулевой баланс', description: 'Лиды отклоняются — нет средств для оплаты.' },
{ id: 'topup_success', label: 'Пополнение успешно', description: 'Платёж зачислен на баланс.' },
{ id: 'invoice_paid', label: 'Счёт оплачен', description: 'Тарифный счёт списан.' },
{ id: 'new_device_login', label: 'Новое устройство', description: 'Вход с незнакомого устройства / IP.' },
{ id: 'marketing', label: 'Анонсы и промо', description: 'Новости платформы, скидки, новые функции.' },
];
interface ChannelKey {
id: NotificationChannel;
label: string;
description: string;
}
const CHANNELS: ChannelKey[] = [
{ id: 'inapp', label: 'В приложении', description: 'Колокольчик в интерфейсе.' },
{ id: 'push', label: 'Push', description: 'Web-push в браузер (включится в Post-MVP).' },
{ id: 'email', label: 'Email', description: 'На адрес в профиле.' },
];
const auth = useAuthStore();
function buildPrefs(): NotificationPreferences {
const userPrefs = auth.user?.notification_preferences ?? {};
const next: NotificationPreferences = {};
for (const e of EVENTS) {
next[e.id] = {};
for (const c of CHANNELS) {
const stored = userPrefs[e.id]?.[c.id];
next[e.id]![c.id] = typeof stored === 'boolean' ? stored : false;
}
}
return next;
}
// Инициализация синхронно в setup() — иначе v-if="prefs[e.id]" блокирует
// рендер чекбоксов до onMounted, и тесты mount-then-find падают.
const prefs = ref<NotificationPreferences>(buildPrefs());
const soundEnabled = ref<boolean>(auth.user?.sound_enabled ?? true);
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(false);
// Snapshot оригинальных значений из auth.user — для определения dirty.
// Replacing с deep-clone чтобы JSON.stringify сравнение работало стабильно.
const originalPrefs = ref<NotificationPreferences>(buildPrefs());
const originalSound = ref<boolean>(auth.user?.sound_enabled ?? true);
const dirty = computed(
() =>
JSON.stringify(prefs.value) !== JSON.stringify(originalPrefs.value) ||
soundEnabled.value !== originalSound.value,
);
function readFromUser(): void {
prefs.value = buildPrefs();
soundEnabled.value = auth.user?.sound_enabled ?? true;
originalPrefs.value = buildPrefs();
originalSound.value = auth.user?.sound_enabled ?? true;
}
watch(() => auth.user?.id, readFromUser);
async function save(): Promise<void> {
if (saving.value) return;
saving.value = true;
saveSuccess.value = false;
saveError.value = false;
try {
const updatedUser = await updateNotificationPreferences({
prefs: prefs.value,
sound_enabled: soundEnabled.value,
});
// Синхронизируем auth.user без повторного /me-вызова.
auth.user = { ...auth.user!, ...updatedUser };
// Обновляем snapshot — dirty снова false.
originalPrefs.value = buildPrefs();
originalSound.value = auth.user.sound_enabled ?? true;
saveSuccess.value = true;
} catch {
saveError.value = true;
} finally {
saving.value = false;
}
}
function reset(): void {
readFromUser();
}
const canSave = computed(() => dirty.value && !saving.value && auth.isAuthenticated);
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">Уведомления</h2>
<v-alert
v-if="saveSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="notifications-save-success"
@click:close="saveSuccess = false"
>
Настройки сохранены.
</v-alert>
<v-alert
v-if="saveError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="notifications-save-error"
@click:close="saveError = false"
>
Не удалось сохранить настройки. Попробуйте позже.
</v-alert>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">События × каналы</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Матрица 8 событий × 3 каналов (соответствует
<code>users.notification_preferences</code> в БД). Push активируется в Post-MVP.
</p>
<div class="prefs-table">
<div class="prefs-head">
<div class="prefs-cell event-col">Событие</div>
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col" :title="ch.description">
{{ ch.label }}
</div>
</div>
<div v-for="e in EVENTS" :key="e.id" class="prefs-row" :data-event="e.id">
<div class="prefs-cell event-col">
<div>{{ e.label }}</div>
<div v-if="e.description" class="event-desc">{{ e.description }}</div>
</div>
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col">
<v-checkbox
v-if="prefs[e.id]"
v-model="prefs[e.id]![ch.id]"
density="compact"
hide-details
color="primary"
:data-testid="`pref-${e.id}-${ch.id}`"
/>
</div>
</div>
</div>
</v-card>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">Звуковые алерты</h3>
<v-switch
v-model="soundEnabled"
color="primary"
hide-details
data-testid="sound-enabled-switch"
label="Воспроизводить звук при новом in-app уведомлении"
/>
</v-card>
<div class="actions-row">
<v-btn color="primary" :loading="saving" :disabled="!canSave" data-testid="save-btn" @click="save">
Сохранить
</v-btn>
<v-btn variant="outlined" :disabled="!dirty || saving" data-testid="reset-btn" @click="reset">
Отменить
</v-btn>
</div>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
.prefs-table {
border: 1px solid #e8e3d6;
border-radius: 8px;
overflow: hidden;
}
.prefs-head,
.prefs-row {
display: grid;
grid-template-columns: 1fr 110px 110px 130px;
align-items: center;
}
.prefs-head {
background: #f6f3ec;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #66635c;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.prefs-row {
border-top: 1px solid #f0ede4;
}
.prefs-cell {
padding: 10px 12px;
}
.event-col {
color: #081319;
font-size: 13px;
}
.event-desc {
font-size: 11px;
color: #66635c;
margin-top: 2px;
}
.ch-col {
text-align: center;
display: flex;
justify-content: center;
}
.actions-row {
display: flex;
gap: 12px;
margin-top: 8px;
}
</style>