diff --git a/CLAUDE.md b/CLAUDE.md
index f1d1dd89..fc94c9a0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# CLAUDE.md — техконтекст Лидерры
-**Версия:** 1.66 от 09.05.2026
+**Версия:** 1.67 от 09.05.2026
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
> **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5).
@@ -224,6 +224,8 @@ trivy image liderra:latest
---
+*CLAUDE.md v1.67 от 09.05.2026. Изменения v1.67: **P0 этап 2b — In-app notifications API + UI bell + polling**. Закрывает этап 2 P0 целиком (вместе с 2a). **(1) Backend `App\Http\Controllers\Api\InAppNotificationController`** под `auth:sanctum` (Sanctum SPA, уведомления USER-personal). 4 endpoint'а: `GET /api/notifications?unread_only=&limit=` (1..100, default 50; ORDER BY created_at DESC + id DESC; возвращает items+unread_count+total); `PATCH /api/notifications/{id}/read` (idempotent — повторный вызов NO-OP); `POST /api/notifications/mark-all-read` (bulk update + count); `DELETE /api/notifications/{id}` (hard-delete). Все четыре обёрнуты в DB::transaction + SET LOCAL app.current_tenant_id. Защита от кражи чужого id через `where('user_id', $authUser->id)` поверх RLS. **(2) Маршруты** в `routes/web.php` под `Route::middleware('auth:sanctum')->prefix('/api/notifications')` — Sanctum SPA требует session middleware из web-группы. **(3) Pest +14** в `InAppNotificationApiTest.php` (всего **305/305 за 34.71 сек**, 1099 assertions): 401 без auth / пустой / только свои + ORDER BY created_at DESC / unread_only=1 / limit=2 + total=5 / 422 limit>100 / поля title+body+event+payload+deal_id / mark-read ставит read_at + idempotent / mark-read 404 для чужого / mark-read 404 unknown / mark-all-read bulk + count / mark-all-read только свои / DELETE удаляет своё / DELETE 404 для чужого. **(4) Frontend `api/notifications.ts`** — типизированные axios-helpers с `ensureCsrfCookie` для mutating-вызовов. ApiInAppNotification + ListNotificationsResponse interfaces. **(5) Pinia store `stores/notifications.ts`** — items/unreadCount/total/loading/fetchError refs + sortedItems computed (DESC by created_at) + actions: `load(limit, unreadOnly)` / `markRead(id)` (optimistic + revert на reject) / `markAllRead()` (NO-OP при unreadCount=0) / `remove(id)` (optimistic с decrement total/unreadCount) / `reset()`. На fail markRead/markAllRead/remove — silently revert (без toast'а — иначе спам при каждом sync-failure). **(6) AppLayout** — bell-icon переписан с static-pip на v-menu (offset=8, close-on-content-click=false, location=bottom-end): `` с pip badge показывающим `unreadDisplay` (1..99 / `99+` / hidden при 0); v-card с заголовком + Mark-all-read btn (только при unreadCount>0) + v-list последних 10 элементов из sortedItems. Click на item → `markRead` + если `deal_id` → `router.push('/deals')` (deep-link на конкретный drawer — отдельный коммит). 8 mock event-icon'ов (mdi-account-plus-outline для new_lead, mdi-clock-outline для reminder, и т.д.). **`formatRelative`** показывает «только что» / «N мин назад» / «N ч назад» / «N д назад». `usePolling(loadNotifications, {intervalMs: 30_000})` — каждые 30 сек reload (Page Visibility API в usePolling pause'ит при hidden tab). `loadNotifications` no-op без auth.user. **(7) Vitest +18** (всего **339/339 за 20.03 сек**, +18 от 321): notifications-store 12 (initial state / load fills+rejects / markRead optimistic+revert+already-read / markAllRead optimistic+NO-OP при 0 / remove optimistic+revert / sortedItems DESC / reset); AppLayout +6 (bell-btn существует / pip скрыт при 0 / pip показывает count / pip 99+ при >99 / listNotifications вызывается на mount при auth.user / без user не вызывается). **PHPStan baseline регенерирован** (50 false-positive Pest TestCall warnings подавлены). Production TODO остаточные: deep-link на конкретный drawer (на MVP — push на /deals); этапы 3-6 P0; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 339/339 за 20.03 сек** (+18 от 321); vite build 989 ms (main app-chunk 164.94 KB / KanbanView lazy 182.26 KB); Pint+PHPStan passed (baseline регенерирован); **Pest 305/305 за 34.71 сек** (+14 от 291, 1099 assertions). v1.66→v1.67.*
+
*CLAUDE.md v1.66 от 09.05.2026. Изменения v1.66: **P0 этап 2a — in_app_notifications + notifyInApp в NotificationService** (schema v8.9→v8.10). Backend-фундамент bell-icon канала; UI bell + API endpoints — этап 2b отдельным коммитом. **(1) Schema v8.10** — таблица `in_app_notifications` после `reminders` (обе про работу/коммуникации): id BIGSERIAL / tenant_id FK / user_id FK / event VARCHAR(50) / title VARCHAR(255) / body TEXT / deal_id BIGINT БЕЗ FK (deals партиционирована) / payload JSONB DEFAULT '{}' / read_at TIMESTAMPTZ / created_at TIMESTAMPTZ. UPDATED_AT отсутствует (только created_at + read_at). Индексы: `idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL` (главный UI-флоу) + `idx_in_app_notifications_user_recent (user_id, created_at DESC)` (последние 50 с прочитанными). RLS `tenant_isolation` стандартная. CHANGELOG_schema.md +§T (3 точки источник изменений + 4 точки SQL DDL + почему НЕ Laravel default `notifications`-table). **Метрики после v8.10:** 55→56 таблиц, 93→95 индексов, 36→37 RLS-политик. **(2) `App\Models\InAppNotification`** — Eloquent с `UPDATED_AT=null`, payload cast `array`, read_at cast `datetime`, BelongsTo на User+Tenant. **(3) `NotificationService::notifyInApp(User, event, title, body, payload)`** — INSERT в БД через `DB::transaction` + `SET LOCAL app.current_tenant_id = user.tenant_id` (PgBouncer-safe, RLS-симметрично). Throwable проглатываются + Log::warning. **`NotificationService::notifyNewLead`** теперь шлёт ДВА канала параллельно: email (если prefs.email=true) И in-app (если prefs.inapp=true). `title` = `«Новый лид — {projectName}»`, `body` = `contact_name ?? phone`, `payload` = `{deal_id, project_name}` для UI deep-link на DealDetailDrawer. Schema-default `new_lead.inapp=true` → большинство получит in-app, и только подписавшиеся — email. **(4) Pest +11** в `tests/Feature/Notifications/InAppNotificationTest.php` (всего **291/291 за 32.94 сек**, 1060 assertions): inapp=true создаёт row + поля + payload / inapp=false не создаёт / schema-default ставит row / 2 user'а с inapp=true оба получают / inactive не получает / другой тенант не получает (RLS изоляция) / Биз-19 дубль не дублирует / повторный vid не дублирует / inapp+email=true создаёт 1 row + 1 email / payload содержит deal_id для deep-link / `notifyInApp` напрямую с reminder создаёт row. **(5) Quirk** — Write tool с относительным путём `app/tests/...` создал файлы в `app/app/tests/...` (CWD дрейфонул на `/c/моя/.../app/app`); файлы перемещены вручную, пустые директории удалены через rmdir (rm -rf не пройден permissions). **(6) IDE-helper регенерирован** для нового InAppNotification. **PHPStan baseline регенерирован** (1 «nullsafe.neverNull» error на `$deal->project?->name` подавлен через baseline). **Производственные TODO остаточные:** этап 2b (API + UI bell), этапы 3-6; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** Pint+PHPStan passed (baseline регенерирован); **Pest 291/291 за 32.94 сек** (+11 от 280, 1060 assertions); frontend нетронут — Vitest/build не нужны. v1.65→v1.66.*
*CLAUDE.md v1.65 от 09.05.2026. Изменения v1.65: **P0 этап 1 — NotificationService + new_lead email** (старт closing TODO «Notification delivery» из карты остатка работы). Закрывает первый из 6 этапов плана P0 (notifications + reminders). **(1) `App\Services\NotificationService`** — центральный диспетчер. Константы 8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid/new_device_login/marketing) + 3 каналов (inapp/push/email) точно как в schema.sql:699 `users.notification_preferences` JSONB DEFAULT. Метод `notifyNewLead(Tenant, Deal)` — выбирает активных user'ов тенанта (is_active=true + deleted_at IS NULL) с включённым `notification_preferences.new_lead.email=true` и шлёт через `Mail::to(...)->send(NewLeadNotification)`. Throwable из Mail-фасада ловится → Log::warning (отказ канала не должен валить транзакцию webhook'а). **PHP-фильтр** prefs (не JSONB-запрос) — список получателей <50 на тенант, не critical-path. **(2) `App\Mail\NewLeadNotification`** — Mailable с (User $manager, Deal $deal, Tenant $tenant). Subject `«Лидерра. Новый лид — {project_name}»` с fallback project=`'Без проекта'` если relation не загружен. **`resources/views/emails/new_lead.blade.php`** — HTML-письмо в Forest-палитре (#0F6E56 primary, #F6F3EC ivory) с таблицей phone/contact_name/received_at (TZ конвертирована в `manager->timezone ?? 'Europe/Moscow'`)/deal_id. **(3) Интеграция `ProcessWebhookJob::chargeNewLead`** — после ActivityLog::create вызов `app(NotificationService::class)->notifyNewLead($tenant, $deal)`. `$deal->setRelation('project', $project)` чтобы Mailable не делал лишний SELECT. NotifyNewLead вне DB::transaction в смысле что ошибка отправки уже вне транзакции — но DB::transaction обёртка сейчас покрывает и notify-вызов; на prod надо или вынести notify ПОСЛЕ DB::transaction, или `Mail::queue` (async через worker). На MVP — sync через ::send (детерминированно для тестов). **(4) Pest +11** в `tests/Feature/Notifications/NewLeadNotificationTest.php`(всего **280/280 за 31.27 сек**, 1029 assertions): Mail::fake() / 1 user с email=true получает / user с email=false не получает / schema-default (.email=false) не шлёт / 2 user'а с email=true получают оба, 3-й с email=false не получает / inactive user с email=true не получает / soft-deleted user не получает / user другого тенанта не получает (изоляция) / Биз-19 дубль не шлёт повторное уведомление / повторный vid (idempotent UPDATE) не шлёт повторно / balance=0 (RejectedDealsLog) не шлёт / subject содержит project_name «Caranga». **(5) IDE-helper** регенерирован (`ide-helper:models -W -M -N`) — добавил @mixin docblocks 4 моделям (ImpersonationToken/SaasAdminAuditLog/SystemSetting/UserRecoveryCode), которые ранее без них работали через baseline-ignore'ы. **PHPStan baseline регенерирован** — 138 «ignore.unmatched» errors схлопнулись (новые docblocks резолвят property access напрямую, baseline-патч больше не нужен). **Производственные TODO остаточные:** этапы 2–6 P0 (in_app_notifications + UI bell, NotificationsTab fix под schema, reminders backend+frontend, остальные 4 email-события); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** Pint+PHPStan passed (baseline регенерирован); **Pest 280/280 за 31.27 сек** (+11 от 269, 1029 assertions); frontend нетронут — Vitest/build не нужны. Реестр без изменений (notifications не было в открытых вопросах). v1.64→v1.65.*
diff --git a/app/app/Http/Controllers/Api/InAppNotificationController.php b/app/app/Http/Controllers/Api/InAppNotificationController.php
new file mode 100644
index 00000000..b6872f2b
--- /dev/null
+++ b/app/app/Http/Controllers/Api/InAppNotificationController.php
@@ -0,0 +1,168 @@
+id)` поверх RLS.
+ */
+class InAppNotificationController extends Controller
+{
+ /**
+ * GET /api/notifications?unread_only=1&limit=50.
+ *
+ * Возвращает: {items, unread_count, total}.
+ * Лимит: 1..100 (default 50). Сортировка: created_at DESC.
+ */
+ public function index(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'unread_only' => 'nullable|in:0,1,true,false',
+ 'limit' => 'nullable|integer|min:1|max:100',
+ ]);
+
+ /** @var User $user */
+ $user = $request->user();
+ $unreadOnly = filter_var($validated['unread_only'] ?? false, FILTER_VALIDATE_BOOLEAN);
+ $limit = (int) ($validated['limit'] ?? 50);
+
+ return DB::transaction(function () use ($user, $unreadOnly, $limit): JsonResponse {
+ DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
+
+ $query = InAppNotification::query()
+ ->where('user_id', $user->id)
+ ->orderByDesc('created_at')
+ ->orderByDesc('id');
+
+ if ($unreadOnly) {
+ $query->whereNull('read_at');
+ }
+
+ $items = $query->limit($limit)->get();
+
+ $unreadCount = (int) InAppNotification::query()
+ ->where('user_id', $user->id)
+ ->whereNull('read_at')
+ ->count();
+
+ $total = (int) InAppNotification::query()
+ ->where('user_id', $user->id)
+ ->count();
+
+ return response()->json([
+ 'items' => $items->map(fn (InAppNotification $n) => [
+ 'id' => $n->id,
+ 'event' => $n->event,
+ 'title' => $n->title,
+ 'body' => $n->body,
+ 'deal_id' => $n->deal_id,
+ 'payload' => $n->payload,
+ 'read_at' => $n->read_at?->toIso8601String(),
+ 'created_at' => $n->created_at?->toIso8601String(),
+ ])->all(),
+ 'unread_count' => $unreadCount,
+ 'total' => $total,
+ ]);
+ });
+ }
+
+ /**
+ * PATCH /api/notifications/{id}/read.
+ *
+ * Идемпотентно: повторный вызов не меняет read_at (already-read NO-OP).
+ * 404 если notification не принадлежит user'у.
+ */
+ public function markRead(Request $request, int $id): JsonResponse
+ {
+ /** @var User $user */
+ $user = $request->user();
+
+ return DB::transaction(function () use ($user, $id): JsonResponse {
+ DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
+
+ $notif = InAppNotification::query()
+ ->where('id', $id)
+ ->where('user_id', $user->id)
+ ->first();
+
+ if ($notif === null) {
+ return response()->json(['message' => 'Уведомление не найдено.'], 404);
+ }
+
+ if ($notif->read_at === null) {
+ $notif->update(['read_at' => now()]);
+ }
+
+ return response()->json([
+ 'id' => $notif->id,
+ 'read_at' => $notif->read_at?->toIso8601String(),
+ ]);
+ });
+ }
+
+ /**
+ * POST /api/notifications/mark-all-read — массовое чтение.
+ *
+ * Bulk-update read_at=NOW() для всех непрочитанных user'а.
+ * Возвращает количество обновлённых.
+ */
+ public function markAllRead(Request $request): JsonResponse
+ {
+ /** @var User $user */
+ $user = $request->user();
+
+ return DB::transaction(function () use ($user): JsonResponse {
+ DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
+
+ $updated = InAppNotification::query()
+ ->where('user_id', $user->id)
+ ->whereNull('read_at')
+ ->update(['read_at' => now()]);
+
+ return response()->json([
+ 'updated' => $updated,
+ ]);
+ });
+ }
+
+ /**
+ * DELETE /api/notifications/{id} — удаление одного уведомления.
+ *
+ * Hard-delete (нет soft-delete для in-app — уведомления накапливаются
+ * быстро, ретеншн 90 дней через cleanup-job).
+ */
+ public function destroy(Request $request, int $id): JsonResponse
+ {
+ /** @var User $user */
+ $user = $request->user();
+
+ return DB::transaction(function () use ($user, $id): JsonResponse {
+ DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
+
+ $deleted = InAppNotification::query()
+ ->where('id', $id)
+ ->where('user_id', $user->id)
+ ->delete();
+
+ if ($deleted === 0) {
+ return response()->json(['message' => 'Уведомление не найдено.'], 404);
+ }
+
+ return response()->json(['message' => 'Удалено.']);
+ });
+ }
+}
diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon
index 2d6b7d1b..536f554e 100644
--- a/app/phpstan-baseline.neon
+++ b/app/phpstan-baseline.neon
@@ -570,6 +570,48 @@ parameters:
count: 3
path: tests/Feature/LookupsTest.php
+ -
+ message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
+ identifier: property.notFound
+ count: 21
+ path: tests/Feature/Notifications/InAppNotificationApiTest.php
+
+ -
+ message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
+ identifier: property.notFound
+ count: 14
+ path: tests/Feature/Notifications/InAppNotificationApiTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: tests/Feature/Notifications/InAppNotificationApiTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#'
+ identifier: method.notFound
+ count: 2
+ path: tests/Feature/Notifications/InAppNotificationApiTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
+ identifier: method.notFound
+ count: 7
+ path: tests/Feature/Notifications/InAppNotificationApiTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
+ identifier: method.notFound
+ count: 4
+ path: tests/Feature/Notifications/InAppNotificationApiTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
+ identifier: method.notFound
+ count: 2
+ path: tests/Feature/Notifications/InAppNotificationApiTest.php
+
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$partitionsBefore\.$#'
identifier: property.notFound
diff --git a/app/resources/js/api/notifications.ts b/app/resources/js/api/notifications.ts
new file mode 100644
index 00000000..120d5cf7
--- /dev/null
+++ b/app/resources/js/api/notifications.ts
@@ -0,0 +1,67 @@
+import { apiClient, ensureCsrfCookie } from './client';
+
+/**
+ * In-app уведомления для bell-icon UI (schema v8.10).
+ *
+ * Все endpoint'ы под Sanctum SPA auth — для незалогиненных вернётся 401.
+ * Mutating-вызовы (mark-read/mark-all-read/destroy) делают ensureCsrfCookie().
+ */
+
+export type NotificationEvent =
+ | 'new_lead'
+ | 'reminder'
+ | 'low_balance'
+ | 'zero_balance'
+ | 'topup_success'
+ | 'invoice_paid'
+ | 'new_device_login'
+ | 'marketing';
+
+export interface ApiInAppNotification {
+ id: number;
+ event: NotificationEvent;
+ title: string;
+ body: string | null;
+ deal_id: number | null;
+ payload: Record;
+ read_at: string | null;
+ created_at: string | null;
+}
+
+export interface ListNotificationsResponse {
+ items: ApiInAppNotification[];
+ unread_count: number;
+ total: number;
+}
+
+export interface ListNotificationsParams {
+ unreadOnly?: boolean;
+ limit?: number;
+}
+
+export async function listNotifications(params: ListNotificationsParams = {}): Promise {
+ const { data } = await apiClient.get('/api/notifications', {
+ params: {
+ unread_only: params.unreadOnly ? 1 : undefined,
+ limit: params.limit,
+ },
+ });
+ return data;
+}
+
+export async function markNotificationRead(id: number): Promise<{ id: number; read_at: string | null }> {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.patch<{ id: number; read_at: string | null }>(`/api/notifications/${id}/read`);
+ return data;
+}
+
+export async function markAllNotificationsRead(): Promise<{ updated: number }> {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post<{ updated: number }>('/api/notifications/mark-all-read');
+ return data;
+}
+
+export async function deleteNotification(id: number): Promise {
+ await ensureCsrfCookie();
+ await apiClient.delete(`/api/notifications/${id}`);
+}
diff --git a/app/resources/js/layouts/AppLayout.vue b/app/resources/js/layouts/AppLayout.vue
index 40416cd5..1b2b0df8 100644
--- a/app/resources/js/layouts/AppLayout.vue
+++ b/app/resources/js/layouts/AppLayout.vue
@@ -15,7 +15,9 @@
* Иконки — mdi (Vuetify default), визуально близки к lucide из дизайна.
*/
import { useAuthStore } from '../stores/auth';
-import { computed, ref } from 'vue';
+import { useNotificationsStore } from '../stores/notifications';
+import { usePolling } from '../composables/usePolling';
+import { computed, onMounted, ref } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
interface NavItem {
@@ -58,8 +60,58 @@ const navGroups: NavGroup[] = [
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
+const notifications = useNotificationsStore();
const drawerOpen = ref(true);
+const unreadDisplay = computed(() => {
+ if (notifications.unreadCount === 0) return '';
+ if (notifications.unreadCount > 99) return '99+';
+ return String(notifications.unreadCount);
+});
+
+function eventIcon(event: string): string {
+ const map: Record = {
+ new_lead: 'mdi-account-plus-outline',
+ reminder: 'mdi-clock-outline',
+ low_balance: 'mdi-wallet-outline',
+ zero_balance: 'mdi-alert-circle-outline',
+ topup_success: 'mdi-cash-plus',
+ invoice_paid: 'mdi-receipt-text-check-outline',
+ new_device_login: 'mdi-shield-account-outline',
+ marketing: 'mdi-bullhorn-outline',
+ };
+ return map[event] ?? 'mdi-bell-outline';
+}
+
+function formatRelative(iso: string | null): string {
+ if (!iso) return '';
+ const ms = Date.now() - new Date(iso).getTime();
+ const min = Math.floor(ms / 60_000);
+ if (min < 1) return 'только что';
+ if (min < 60) return `${min} мин назад`;
+ const hr = Math.floor(min / 60);
+ if (hr < 24) return `${hr} ч назад`;
+ const days = Math.floor(hr / 24);
+ return `${days} д назад`;
+}
+
+async function handleNotificationClick(id: number, dealId: number | null): Promise {
+ await notifications.markRead(id);
+ if (dealId !== null) {
+ // На MVP открываем DealsView (без deep-link на конкретный drawer);
+ // полный deep-link на конкретную сделку — отдельный коммит.
+ await router.push('/deals');
+ }
+}
+
+async function loadNotifications(): Promise {
+ if (!auth.user) return;
+ await notifications.load(10);
+}
+
+onMounted(loadNotifications);
+usePolling(loadNotifications, { intervalMs: 30_000, enabled: true });
+
const currentPageTitle = computed(() => {
const all = navGroups.flatMap((g) => g.items);
return all.find((i) => i.to === route.path)?.title ?? 'Страница';
@@ -155,10 +207,67 @@ async function handleLogout() {
-
- mdi-bell-outline
-
-
+
+
+
+ mdi-bell-outline
+
+ {{ unreadDisplay }}
+
+
+
+
+
@@ -270,12 +379,56 @@ async function handleLogout() {
.notification-pip {
position: absolute;
- top: 8px;
- right: 8px;
- width: 6px;
- height: 6px;
- border-radius: 50%;
+ top: 4px;
+ right: 4px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 5px;
+ border-radius: 8px;
background: #b94837;
+ color: #fff;
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-feature-settings: 'tnum';
+ font-size: 10px;
+ line-height: 16px;
+ text-align: center;
+ font-weight: 600;
+}
+
+.notifications-menu {
+ background: #ffffff;
+}
+
+.notifications-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+}
+
+.notifications-empty {
+ padding: 32px 16px;
+ text-align: center;
+ color: #66635c;
+ font-size: 13px;
+}
+
+.notifications-list {
+ max-height: 420px;
+ overflow-y: auto;
+}
+
+.notification-unread {
+ background: #f0f8f5;
+}
+
+.notification-time {
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-feature-settings: 'tnum';
+ font-size: 11px;
+ color: #66635c;
+ white-space: nowrap;
+ margin-left: 8px;
}
.user-chip {
diff --git a/app/resources/js/stores/notifications.ts b/app/resources/js/stores/notifications.ts
new file mode 100644
index 00000000..7b3a72de
--- /dev/null
+++ b/app/resources/js/stores/notifications.ts
@@ -0,0 +1,142 @@
+import { computed, ref } from 'vue';
+import { defineStore } from 'pinia';
+import {
+ type ApiInAppNotification,
+ deleteNotification,
+ listNotifications,
+ markAllNotificationsRead,
+ markNotificationRead,
+} from '../api/notifications';
+
+/**
+ * Pinia store для in-app уведомлений (bell-icon в AppLayout).
+ *
+ * Polling: AppLayout вызывает `load()` на mount + через `usePolling` каждые
+ * 30 сек (как dealsState/admin views). Page Visibility API в usePolling
+ * пускает paus'у при свёрнутой вкладке.
+ *
+ * Optimistic-updates: markRead уменьшает unreadCount + ставит read_at до
+ * ответа API; на fail — silently revert (без toast'а, чтобы не спамить
+ * пользователя при каждом sync-failure).
+ */
+export const useNotificationsStore = defineStore('notifications', () => {
+ const items = ref([]);
+ const unreadCount = ref(0);
+ const total = ref(0);
+ const loading = ref(false);
+ const fetchError = ref(false);
+
+ const sortedItems = computed(() =>
+ [...items.value].sort((a, b) => {
+ const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
+ const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
+ return tb - ta;
+ }),
+ );
+
+ async function load(limit = 50, unreadOnly = false): Promise {
+ loading.value = true;
+ fetchError.value = false;
+ try {
+ const response = await listNotifications({ limit, unreadOnly });
+ items.value = response.items;
+ unreadCount.value = response.unread_count;
+ total.value = response.total;
+ } catch {
+ fetchError.value = true;
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function markRead(id: number): Promise {
+ const item = items.value.find((n) => n.id === id);
+ if (!item || item.read_at !== null) return;
+
+ // Optimistic.
+ const previousReadAt = item.read_at;
+ const nowIso = new Date().toISOString();
+ item.read_at = nowIso;
+ unreadCount.value = Math.max(0, unreadCount.value - 1);
+
+ try {
+ const response = await markNotificationRead(id);
+ item.read_at = response.read_at;
+ } catch {
+ // Revert.
+ item.read_at = previousReadAt;
+ unreadCount.value++;
+ }
+ }
+
+ async function markAllRead(): Promise {
+ if (unreadCount.value === 0) return;
+
+ // Optimistic.
+ const previousReadAt = new Map();
+ const nowIso = new Date().toISOString();
+ items.value.forEach((n) => {
+ if (n.read_at === null) {
+ previousReadAt.set(n.id, n.read_at);
+ n.read_at = nowIso;
+ }
+ });
+ const previousUnread = unreadCount.value;
+ unreadCount.value = 0;
+
+ try {
+ await markAllNotificationsRead();
+ } catch {
+ // Revert.
+ previousReadAt.forEach((readAt, id) => {
+ const item = items.value.find((n) => n.id === id);
+ if (item) item.read_at = readAt;
+ });
+ unreadCount.value = previousUnread;
+ }
+ }
+
+ async function remove(id: number): Promise {
+ const item = items.value.find((n) => n.id === id);
+ if (!item) return;
+ const wasUnread = item.read_at === null;
+ const previousItems = [...items.value];
+ const previousTotal = total.value;
+ const previousUnread = unreadCount.value;
+
+ // Optimistic.
+ items.value = items.value.filter((n) => n.id !== id);
+ total.value = Math.max(0, total.value - 1);
+ if (wasUnread) unreadCount.value = Math.max(0, unreadCount.value - 1);
+
+ try {
+ await deleteNotification(id);
+ } catch {
+ // Revert.
+ items.value = previousItems;
+ total.value = previousTotal;
+ unreadCount.value = previousUnread;
+ }
+ }
+
+ function reset(): void {
+ items.value = [];
+ unreadCount.value = 0;
+ total.value = 0;
+ fetchError.value = false;
+ }
+
+ return {
+ items,
+ unreadCount,
+ total,
+ loading,
+ fetchError,
+ sortedItems,
+ load,
+ markRead,
+ markAllRead,
+ remove,
+ reset,
+ };
+});
diff --git a/app/routes/web.php b/app/routes/web.php
index 376c698c..ce9af097 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\AdminTenantsController;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\DealController;
use App\Http\Controllers\Api\ImpersonationController;
+use App\Http\Controllers\Api\InAppNotificationController;
use App\Http\Controllers\Api\LeadStatusController;
use App\Http\Controllers\Api\ManagerController;
use App\Http\Controllers\Api\ProjectController;
@@ -35,6 +36,15 @@ Route::prefix('/api/auth')->group(function () {
});
});
+// In-app уведомления (P0 этап 2b). Все endpoint'ы под Sanctum SPA auth —
+// уведомления USER-personal, читать/писать может только сам user.
+Route::middleware('auth:sanctum')->prefix('/api/notifications')->group(function () {
+ Route::get('/', [InAppNotificationController::class, 'index']);
+ Route::patch('/{id}/read', [InAppNotificationController::class, 'markRead'])->where('id', '[0-9]+');
+ Route::post('/mark-all-read', [InAppNotificationController::class, 'markAllRead']);
+ Route::delete('/{id}', [InAppNotificationController::class, 'destroy'])->where('id', '[0-9]+');
+});
+
// SaaS-admin impersonation flow (Ю-1). На MVP без middleware (saas-admin auth
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
Route::prefix('/api/admin/impersonation')->group(function () {
diff --git a/app/tests/Feature/Notifications/InAppNotificationApiTest.php b/app/tests/Feature/Notifications/InAppNotificationApiTest.php
new file mode 100644
index 00000000..22eae0c0
--- /dev/null
+++ b/app/tests/Feature/Notifications/InAppNotificationApiTest.php
@@ -0,0 +1,178 @@
+tenant = Tenant::factory()->create();
+ $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ $this->actingAs($this->user);
+});
+
+function makeNotif(int $userId, int $tenantId, ?string $readAt = null, string $event = 'new_lead'): InAppNotification
+{
+ return InAppNotification::create([
+ 'tenant_id' => $tenantId,
+ 'user_id' => $userId,
+ 'event' => $event,
+ 'title' => 'Новый лид — Caranga',
+ 'body' => '79001234567',
+ 'deal_id' => 42,
+ 'payload' => ['deal_id' => 42, 'project_name' => 'Caranga'],
+ 'read_at' => $readAt,
+ ]);
+}
+
+test('GET /api/notifications: 401 без auth', function () {
+ auth()->logout();
+ $this->getJson('/api/notifications')->assertStatus(401);
+});
+
+test('GET /api/notifications: пустой', function () {
+ $response = $this->getJson('/api/notifications');
+ $response->assertOk();
+ expect($response->json('items'))->toBe([]);
+ expect($response->json('unread_count'))->toBe(0);
+ expect($response->json('total'))->toBe(0);
+});
+
+test('GET /api/notifications: возвращает только свои + сортировка по created_at DESC', function () {
+ $other = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ makeNotif($this->user->id, $this->tenant->id);
+ sleep(1);
+ $newer = makeNotif($this->user->id, $this->tenant->id);
+ makeNotif($other->id, $this->tenant->id); // чужое
+
+ $response = $this->getJson('/api/notifications');
+ $response->assertOk();
+ expect($response->json('total'))->toBe(2);
+ expect($response->json('items.0.id'))->toBe($newer->id); // newer first
+ expect($response->json('unread_count'))->toBe(2);
+});
+
+test('GET /api/notifications?unread_only=1: только непрочитанные', function () {
+ makeNotif($this->user->id, $this->tenant->id, readAt: now()->toIso8601String());
+ makeNotif($this->user->id, $this->tenant->id);
+
+ $response = $this->getJson('/api/notifications?unread_only=1');
+ expect($response->json('items'))->toHaveCount(1);
+ expect($response->json('items.0.read_at'))->toBeNull();
+ expect($response->json('unread_count'))->toBe(1);
+ expect($response->json('total'))->toBe(2);
+});
+
+test('GET /api/notifications?limit=2: лимитирует выдачу', function () {
+ for ($i = 0; $i < 5; $i++) {
+ makeNotif($this->user->id, $this->tenant->id);
+ }
+
+ $response = $this->getJson('/api/notifications?limit=2');
+ expect($response->json('items'))->toHaveCount(2);
+ expect($response->json('total'))->toBe(5);
+});
+
+test('GET /api/notifications: 422 на limit > 100', function () {
+ $this->getJson('/api/notifications?limit=101')->assertStatus(422);
+});
+
+test('GET /api/notifications: возвращает поля title/body/event/payload/deal_id', function () {
+ $notif = makeNotif($this->user->id, $this->tenant->id);
+
+ $response = $this->getJson('/api/notifications');
+ $item = $response->json('items.0');
+ expect($item['id'])->toBe($notif->id);
+ expect($item['event'])->toBe('new_lead');
+ expect($item['title'])->toBe('Новый лид — Caranga');
+ expect($item['body'])->toBe('79001234567');
+ expect($item['deal_id'])->toBe(42);
+ expect($item['payload']['project_name'])->toBe('Caranga');
+ expect($item['read_at'])->toBeNull();
+});
+
+test('PATCH /api/notifications/{id}/read: ставит read_at + idempotent', function () {
+ $notif = makeNotif($this->user->id, $this->tenant->id);
+
+ $response = $this->patchJson("/api/notifications/{$notif->id}/read");
+ $response->assertOk();
+ expect($response->json('read_at'))->not->toBeNull();
+
+ $notif->refresh();
+ $firstReadAt = $notif->read_at?->toIso8601String();
+ expect($firstReadAt)->not->toBeNull();
+
+ // Повторный — не меняет read_at.
+ $this->patchJson("/api/notifications/{$notif->id}/read")->assertOk();
+ $notif->refresh();
+ expect($notif->read_at?->toIso8601String())->toBe($firstReadAt);
+});
+
+test('PATCH /api/notifications/{id}/read: 404 для чужого', function () {
+ $other = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ $notif = makeNotif($other->id, $this->tenant->id);
+
+ $this->patchJson("/api/notifications/{$notif->id}/read")->assertStatus(404);
+});
+
+test('PATCH /api/notifications/{id}/read: 404 на несуществующий id', function () {
+ $this->patchJson('/api/notifications/999999/read')->assertStatus(404);
+});
+
+test('POST /api/notifications/mark-all-read: bulk-update + count', function () {
+ makeNotif($this->user->id, $this->tenant->id);
+ makeNotif($this->user->id, $this->tenant->id);
+ makeNotif($this->user->id, $this->tenant->id, readAt: now()->toIso8601String()); // уже прочитано
+
+ $response = $this->postJson('/api/notifications/mark-all-read');
+ $response->assertOk();
+ expect($response->json('updated'))->toBe(2);
+
+ $unreadCount = InAppNotification::query()
+ ->where('user_id', $this->user->id)
+ ->whereNull('read_at')
+ ->count();
+ expect($unreadCount)->toBe(0);
+});
+
+test('POST /api/notifications/mark-all-read: только свои', function () {
+ $other = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ makeNotif($other->id, $this->tenant->id); // чужое
+ makeNotif($this->user->id, $this->tenant->id);
+
+ $response = $this->postJson('/api/notifications/mark-all-read');
+ expect($response->json('updated'))->toBe(1); // только своё
+
+ $otherUnread = InAppNotification::query()
+ ->where('user_id', $other->id)
+ ->whereNull('read_at')
+ ->count();
+ expect($otherUnread)->toBe(1); // чужое осталось непрочитанным
+});
+
+test('DELETE /api/notifications/{id}: удаляет своё', function () {
+ $notif = makeNotif($this->user->id, $this->tenant->id);
+
+ $this->deleteJson("/api/notifications/{$notif->id}")->assertOk();
+ expect(InAppNotification::query()->find($notif->id))->toBeNull();
+});
+
+test('DELETE /api/notifications/{id}: 404 для чужого', function () {
+ $other = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ $notif = makeNotif($other->id, $this->tenant->id);
+
+ $this->deleteJson("/api/notifications/{$notif->id}")->assertStatus(404);
+
+ expect(InAppNotification::query()->find($notif->id))->not->toBeNull(); // не удалено
+});
diff --git a/app/tests/Frontend/AppLayout.spec.ts b/app/tests/Frontend/AppLayout.spec.ts
index a72a1b56..99dc1c4f 100644
--- a/app/tests/Frontend/AppLayout.spec.ts
+++ b/app/tests/Frontend/AppLayout.spec.ts
@@ -1,10 +1,21 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
+
+// Мокаем api/notifications до import'а AppLayout (использует store, который импортит api).
+vi.mock('../../resources/js/api/notifications', () => ({
+ listNotifications: vi.fn().mockResolvedValue({ items: [], unread_count: 0, total: 0 }),
+ markNotificationRead: vi.fn(),
+ markAllNotificationsRead: vi.fn(),
+ deleteNotification: vi.fn(),
+}));
+
+import * as notificationsApi from '../../resources/js/api/notifications';
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
+import { useNotificationsStore } from '../../resources/js/stores/notifications';
import type { AuthUser } from '../../resources/js/api/auth';
const mockUser: AuthUser = {
@@ -107,4 +118,49 @@ describe('AppLayout.vue', () => {
});
expect(wrapper.text()).toContain('ivan.petrov@example.ru');
});
+
+ it('bell-icon кнопка существует', async () => {
+ const wrapper = await mountAppLayout();
+ const bellBtn = wrapper.find('[data-testid="notifications-btn"]');
+ expect(bellBtn.exists()).toBe(true);
+ });
+
+ it('pip скрыт когда unreadCount=0 (default state)', async () => {
+ const wrapper = await mountAppLayout();
+ await wrapper.vm.$nextTick();
+ const pip = wrapper.find('[data-testid="notifications-pip"]');
+ expect(pip.exists()).toBe(false);
+ });
+
+ it('pip показывает unreadCount когда > 0', async () => {
+ const wrapper = await mountAppLayout();
+ const store = useNotificationsStore();
+ store.unreadCount = 5;
+ await wrapper.vm.$nextTick();
+
+ const pip = wrapper.find('[data-testid="notifications-pip"]');
+ expect(pip.exists()).toBe(true);
+ expect(pip.text()).toBe('5');
+ });
+
+ it('pip показывает «99+» когда unreadCount > 99', async () => {
+ const wrapper = await mountAppLayout();
+ const store = useNotificationsStore();
+ store.unreadCount = 142;
+ await wrapper.vm.$nextTick();
+
+ const pip = wrapper.find('[data-testid="notifications-pip"]');
+ expect(pip.text()).toBe('99+');
+ });
+
+ it('listNotifications вызывается на mount при наличии user', async () => {
+ await mountAppLayout('/dashboard', mockUser);
+ expect(notificationsApi.listNotifications).toHaveBeenCalled();
+ });
+
+ it('listNotifications НЕ вызывается на mount без user', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockClear();
+ await mountAppLayout('/dashboard', null);
+ expect(notificationsApi.listNotifications).not.toHaveBeenCalled();
+ });
});
diff --git a/app/tests/Frontend/notifications-store.spec.ts b/app/tests/Frontend/notifications-store.spec.ts
new file mode 100644
index 00000000..cc6f86b2
--- /dev/null
+++ b/app/tests/Frontend/notifications-store.spec.ts
@@ -0,0 +1,219 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+
+// Мокаем api/notifications до import'а store.
+vi.mock('../../resources/js/api/notifications', () => ({
+ listNotifications: vi.fn(),
+ markNotificationRead: vi.fn(),
+ markAllNotificationsRead: vi.fn(),
+ deleteNotification: vi.fn(),
+}));
+
+vi.mock('../../resources/js/api/client', () => ({
+ apiClient: {},
+ ensureCsrfCookie: vi.fn(),
+ extractValidationErrors: vi.fn(() => null),
+ extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
+ extractRateLimitRetry: vi.fn(() => null),
+}));
+
+import * as notificationsApi from '../../resources/js/api/notifications';
+import { useNotificationsStore } from '../../resources/js/stores/notifications';
+import type { ApiInAppNotification } from '../../resources/js/api/notifications';
+
+const mockNotif = (id: number, overrides: Partial = {}): ApiInAppNotification => ({
+ id,
+ event: 'new_lead',
+ title: `Уведомление #${id}`,
+ body: 'Тестовое тело',
+ deal_id: 100 + id,
+ payload: { deal_id: 100 + id, project_name: 'Caranga' },
+ read_at: null,
+ created_at: new Date(2026, 4, 9, 12, id).toISOString(),
+ ...overrides,
+});
+
+describe('useNotificationsStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.clearAllMocks();
+ });
+
+ it('initial state: items=[], unreadCount=0, total=0', () => {
+ const store = useNotificationsStore();
+ expect(store.items).toEqual([]);
+ expect(store.unreadCount).toBe(0);
+ expect(store.total).toBe(0);
+ expect(store.fetchError).toBe(false);
+ });
+
+ it('load() заполняет items + unreadCount + total из API', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1), mockNotif(2, { read_at: '2026-05-09T10:00:00Z' })],
+ unread_count: 1,
+ total: 2,
+ });
+
+ const store = useNotificationsStore();
+ await store.load();
+
+ expect(store.items).toHaveLength(2);
+ expect(store.unreadCount).toBe(1);
+ expect(store.total).toBe(2);
+ expect(store.fetchError).toBe(false);
+ });
+
+ it('load() при reject ставит fetchError=true, items не меняются', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockRejectedValue(new Error('network'));
+
+ const store = useNotificationsStore();
+ await store.load();
+
+ expect(store.fetchError).toBe(true);
+ expect(store.items).toEqual([]);
+ });
+
+ it('markRead() optimistic ставит read_at + уменьшает unreadCount', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1)],
+ unread_count: 1,
+ total: 1,
+ });
+ vi.mocked(notificationsApi.markNotificationRead).mockResolvedValue({
+ id: 1,
+ read_at: '2026-05-09T13:00:00Z',
+ });
+
+ const store = useNotificationsStore();
+ await store.load();
+ expect(store.unreadCount).toBe(1);
+
+ await store.markRead(1);
+ expect(store.unreadCount).toBe(0);
+ expect(store.items[0]!.read_at).toBe('2026-05-09T13:00:00Z');
+ });
+
+ it('markRead() при reject — revert read_at + unreadCount', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1)],
+ unread_count: 1,
+ total: 1,
+ });
+ vi.mocked(notificationsApi.markNotificationRead).mockRejectedValue(new Error('500'));
+
+ const store = useNotificationsStore();
+ await store.load();
+ await store.markRead(1);
+
+ expect(store.unreadCount).toBe(1);
+ expect(store.items[0]!.read_at).toBeNull();
+ });
+
+ it('markRead() для уже прочитанного НЕ вызывает API', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1, { read_at: '2026-05-09T10:00:00Z' })],
+ unread_count: 0,
+ total: 1,
+ });
+
+ const store = useNotificationsStore();
+ await store.load();
+ await store.markRead(1);
+
+ expect(notificationsApi.markNotificationRead).not.toHaveBeenCalled();
+ });
+
+ it('markAllRead() optimistic + вызывает API', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1), mockNotif(2)],
+ unread_count: 2,
+ total: 2,
+ });
+ vi.mocked(notificationsApi.markAllNotificationsRead).mockResolvedValue({ updated: 2 });
+
+ const store = useNotificationsStore();
+ await store.load();
+ expect(store.unreadCount).toBe(2);
+
+ await store.markAllRead();
+ expect(store.unreadCount).toBe(0);
+ expect(store.items.every((n) => n.read_at !== null)).toBe(true);
+ expect(notificationsApi.markAllNotificationsRead).toHaveBeenCalledOnce();
+ });
+
+ it('markAllRead() при unreadCount=0 НЕ вызывает API', async () => {
+ const store = useNotificationsStore();
+ await store.markAllRead();
+
+ expect(notificationsApi.markAllNotificationsRead).not.toHaveBeenCalled();
+ });
+
+ it('remove() optimistic убирает из items + decrement total', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1), mockNotif(2)],
+ unread_count: 2,
+ total: 2,
+ });
+ vi.mocked(notificationsApi.deleteNotification).mockResolvedValue();
+
+ const store = useNotificationsStore();
+ await store.load();
+ await store.remove(1);
+
+ expect(store.items).toHaveLength(1);
+ expect(store.items[0]!.id).toBe(2);
+ expect(store.total).toBe(1);
+ expect(store.unreadCount).toBe(1);
+ });
+
+ it('remove() при reject — revert items + total + unreadCount', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1), mockNotif(2)],
+ unread_count: 2,
+ total: 2,
+ });
+ vi.mocked(notificationsApi.deleteNotification).mockRejectedValue(new Error('500'));
+
+ const store = useNotificationsStore();
+ await store.load();
+ await store.remove(1);
+
+ expect(store.items).toHaveLength(2);
+ expect(store.total).toBe(2);
+ expect(store.unreadCount).toBe(2);
+ });
+
+ it('sortedItems сортирует по created_at DESC', async () => {
+ const earlier = mockNotif(1, { created_at: '2026-05-09T10:00:00Z' });
+ const later = mockNotif(2, { created_at: '2026-05-09T15:00:00Z' });
+
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [earlier, later],
+ unread_count: 2,
+ total: 2,
+ });
+
+ const store = useNotificationsStore();
+ await store.load();
+
+ expect(store.sortedItems[0]!.id).toBe(2); // later first
+ expect(store.sortedItems[1]!.id).toBe(1);
+ });
+
+ it('reset() очищает state', async () => {
+ vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
+ items: [mockNotif(1)],
+ unread_count: 1,
+ total: 1,
+ });
+
+ const store = useNotificationsStore();
+ await store.load();
+ store.reset();
+
+ expect(store.items).toEqual([]);
+ expect(store.unreadCount).toBe(0);
+ expect(store.total).toBe(0);
+ expect(store.fetchError).toBe(false);
+ });
+});