f55b91cfa4
Закрывает архитектурное расхождение 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>
269 lines
9.4 KiB
Vue
269 lines
9.4 KiB
Vue
<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>
|