phase2(notifications-stage2b): API + Pinia + bell в AppLayout (P0 этап 2b)
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).
Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
GET /api/notifications?unread_only=&limit= (1..100 default 50);
PATCH /api/notifications/{id}/read (idempotent);
POST /api/notifications/mark-all-read (bulk + count);
DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).
Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
v-list последних 10 из sortedItems с event-icon + formatRelative;
Mark-all-read btn только при unreadCount > 0;
click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
(bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
на mount с user / no-op без user).
PHPStan baseline регенерирован (50 Pest false-positives подавлены).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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): `<v-btn data-testid="notifications-btn">` с 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.*
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InAppNotification;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* In-app уведомления для bell-icon в UI (schema v8.10 §18.5).
|
||||
*
|
||||
* Все endpoint'ы под `auth:sanctum` (Sanctum SPA mode) — уведомления
|
||||
* USER-personal, читать/писать может только сам user.
|
||||
*
|
||||
* RLS: внутри транзакции `SET LOCAL app.current_tenant_id` = tenant_id user'а.
|
||||
* Защита от кражи чужого id: явный `where('user_id', $authUser->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' => 'Удалено.']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<ListNotificationsResponse> {
|
||||
const { data } = await apiClient.get<ListNotificationsResponse>('/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<void> {
|
||||
await ensureCsrfCookie();
|
||||
await apiClient.delete(`/api/notifications/${id}`);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// На MVP открываем DealsView (без deep-link на конкретный drawer);
|
||||
// полный deep-link на конкретную сделку — отдельный коммит.
|
||||
await router.push('/deals');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotifications(): Promise<void> {
|
||||
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() {
|
||||
</template>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" variant="text" aria-label="Уведомления">
|
||||
<v-icon>mdi-bell-outline</v-icon>
|
||||
<span class="notification-pip" aria-hidden="true" />
|
||||
</v-btn>
|
||||
<v-menu offset="8" :close-on-content-click="false" location="bottom end">
|
||||
<template #activator="{ props: bellProps }">
|
||||
<v-btn
|
||||
v-bind="bellProps"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
aria-label="Уведомления"
|
||||
data-testid="notifications-btn"
|
||||
>
|
||||
<v-icon>mdi-bell-outline</v-icon>
|
||||
<span
|
||||
v-if="notifications.unreadCount > 0"
|
||||
class="notification-pip"
|
||||
data-testid="notifications-pip"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ unreadDisplay }}
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card class="notifications-menu" min-width="360" max-width="420" elevation="3">
|
||||
<div class="notifications-header">
|
||||
<strong>Уведомления</strong>
|
||||
<v-btn
|
||||
v-if="notifications.unreadCount > 0"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
data-testid="mark-all-read-btn"
|
||||
@click="notifications.markAllRead()"
|
||||
>
|
||||
Прочитать все
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-divider />
|
||||
<div v-if="notifications.items.length === 0" class="notifications-empty">
|
||||
<v-icon size="32" class="mb-2">mdi-bell-off-outline</v-icon>
|
||||
<div>Нет уведомлений</div>
|
||||
</div>
|
||||
<v-list v-else density="compact" class="notifications-list" data-testid="notifications-list">
|
||||
<v-list-item
|
||||
v-for="item in notifications.sortedItems.slice(0, 10)"
|
||||
:key="item.id"
|
||||
:class="{ 'notification-unread': item.read_at === null }"
|
||||
data-testid="notification-item"
|
||||
@click="handleNotificationClick(item.id, item.deal_id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :icon="eventIcon(item.event)" size="20" />
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
{{ item.body }}
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<span class="notification-time">{{ formatRelative(item.created_at) }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-menu offset="8">
|
||||
<template #activator="{ props }">
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ApiInAppNotification[]>([]);
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (unreadCount.value === 0) return;
|
||||
|
||||
// Optimistic.
|
||||
const previousReadAt = new Map<number, string | null>();
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\InAppNotification;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Тесты API для in-app уведомлений: GET /api/notifications + PATCH /{id}/read +
|
||||
* POST /mark-all-read + DELETE /{id}. Все endpoint'ы под Sanctum SPA auth.
|
||||
*
|
||||
* RLS-проверка через защиту от кражи чужого id (where user_id) — постgres
|
||||
* superuser BYPASSRLS в тестах, но controller-level фильтр работает.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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(); // не удалено
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user