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:
Дмитрий
2026-05-09 11:27:57 +03:00
parent 7f5ff874a8
commit 508de4eaf3
10 changed files with 1049 additions and 12 deletions
+3 -1
View File
@@ -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 остаточные:** этапы 26 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' => 'Удалено.']);
});
}
}
+42
View File
@@ -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
+67
View File
@@ -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}`);
}
+163 -10
View File
@@ -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 {
+142
View File
@@ -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,
};
});
+10
View File
@@ -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(); // не удалено
});
+57 -1
View File
@@ -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);
});
});