diff --git a/CLAUDE.md b/CLAUDE.md index f1d1dd89..fc94c9a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.66 от 09.05.2026 +**Версия:** 1.67 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.67 от 09.05.2026. Изменения v1.67: **P0 этап 2b — In-app notifications API + UI bell + polling**. Закрывает этап 2 P0 целиком (вместе с 2a). **(1) Backend `App\Http\Controllers\Api\InAppNotificationController`** под `auth:sanctum` (Sanctum SPA, уведомления USER-personal). 4 endpoint'а: `GET /api/notifications?unread_only=&limit=` (1..100, default 50; ORDER BY created_at DESC + id DESC; возвращает items+unread_count+total); `PATCH /api/notifications/{id}/read` (idempotent — повторный вызов NO-OP); `POST /api/notifications/mark-all-read` (bulk update + count); `DELETE /api/notifications/{id}` (hard-delete). Все четыре обёрнуты в DB::transaction + SET LOCAL app.current_tenant_id. Защита от кражи чужого id через `where('user_id', $authUser->id)` поверх RLS. **(2) Маршруты** в `routes/web.php` под `Route::middleware('auth:sanctum')->prefix('/api/notifications')` — Sanctum SPA требует session middleware из web-группы. **(3) Pest +14** в `InAppNotificationApiTest.php` (всего **305/305 за 34.71 сек**, 1099 assertions): 401 без auth / пустой / только свои + ORDER BY created_at DESC / unread_only=1 / limit=2 + total=5 / 422 limit>100 / поля title+body+event+payload+deal_id / mark-read ставит read_at + idempotent / mark-read 404 для чужого / mark-read 404 unknown / mark-all-read bulk + count / mark-all-read только свои / DELETE удаляет своё / DELETE 404 для чужого. **(4) Frontend `api/notifications.ts`** — типизированные axios-helpers с `ensureCsrfCookie` для mutating-вызовов. ApiInAppNotification + ListNotificationsResponse interfaces. **(5) Pinia store `stores/notifications.ts`** — items/unreadCount/total/loading/fetchError refs + sortedItems computed (DESC by created_at) + actions: `load(limit, unreadOnly)` / `markRead(id)` (optimistic + revert на reject) / `markAllRead()` (NO-OP при unreadCount=0) / `remove(id)` (optimistic с decrement total/unreadCount) / `reset()`. На fail markRead/markAllRead/remove — silently revert (без toast'а — иначе спам при каждом sync-failure). **(6) AppLayout** — bell-icon переписан с static-pip на v-menu (offset=8, close-on-content-click=false, location=bottom-end): `` с pip badge показывающим `unreadDisplay` (1..99 / `99+` / hidden при 0); v-card с заголовком + Mark-all-read btn (только при unreadCount>0) + v-list последних 10 элементов из sortedItems. Click на item → `markRead` + если `deal_id` → `router.push('/deals')` (deep-link на конкретный drawer — отдельный коммит). 8 mock event-icon'ов (mdi-account-plus-outline для new_lead, mdi-clock-outline для reminder, и т.д.). **`formatRelative`** показывает «только что» / «N мин назад» / «N ч назад» / «N д назад». `usePolling(loadNotifications, {intervalMs: 30_000})` — каждые 30 сек reload (Page Visibility API в usePolling pause'ит при hidden tab). `loadNotifications` no-op без auth.user. **(7) Vitest +18** (всего **339/339 за 20.03 сек**, +18 от 321): notifications-store 12 (initial state / load fills+rejects / markRead optimistic+revert+already-read / markAllRead optimistic+NO-OP при 0 / remove optimistic+revert / sortedItems DESC / reset); AppLayout +6 (bell-btn существует / pip скрыт при 0 / pip показывает count / pip 99+ при >99 / listNotifications вызывается на mount при auth.user / без user не вызывается). **PHPStan baseline регенерирован** (50 false-positive Pest TestCall warnings подавлены). Production TODO остаточные: deep-link на конкретный drawer (на MVP — push на /deals); этапы 3-6 P0; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 339/339 за 20.03 сек** (+18 от 321); vite build 989 ms (main app-chunk 164.94 KB / KanbanView lazy 182.26 KB); Pint+PHPStan passed (baseline регенерирован); **Pest 305/305 за 34.71 сек** (+14 от 291, 1099 assertions). v1.66→v1.67.* + *CLAUDE.md v1.66 от 09.05.2026. Изменения v1.66: **P0 этап 2a — in_app_notifications + notifyInApp в NotificationService** (schema v8.9→v8.10). Backend-фундамент bell-icon канала; UI bell + API endpoints — этап 2b отдельным коммитом. **(1) Schema v8.10** — таблица `in_app_notifications` после `reminders` (обе про работу/коммуникации): id BIGSERIAL / tenant_id FK / user_id FK / event VARCHAR(50) / title VARCHAR(255) / body TEXT / deal_id BIGINT БЕЗ FK (deals партиционирована) / payload JSONB DEFAULT '{}' / read_at TIMESTAMPTZ / created_at TIMESTAMPTZ. UPDATED_AT отсутствует (только created_at + read_at). Индексы: `idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL` (главный UI-флоу) + `idx_in_app_notifications_user_recent (user_id, created_at DESC)` (последние 50 с прочитанными). RLS `tenant_isolation` стандартная. CHANGELOG_schema.md +§T (3 точки источник изменений + 4 точки SQL DDL + почему НЕ Laravel default `notifications`-table). **Метрики после v8.10:** 55→56 таблиц, 93→95 индексов, 36→37 RLS-политик. **(2) `App\Models\InAppNotification`** — Eloquent с `UPDATED_AT=null`, payload cast `array`, read_at cast `datetime`, BelongsTo на User+Tenant. **(3) `NotificationService::notifyInApp(User, event, title, body, payload)`** — INSERT в БД через `DB::transaction` + `SET LOCAL app.current_tenant_id = user.tenant_id` (PgBouncer-safe, RLS-симметрично). Throwable проглатываются + Log::warning. **`NotificationService::notifyNewLead`** теперь шлёт ДВА канала параллельно: email (если prefs.email=true) И in-app (если prefs.inapp=true). `title` = `«Новый лид — {projectName}»`, `body` = `contact_name ?? phone`, `payload` = `{deal_id, project_name}` для UI deep-link на DealDetailDrawer. Schema-default `new_lead.inapp=true` → большинство получит in-app, и только подписавшиеся — email. **(4) Pest +11** в `tests/Feature/Notifications/InAppNotificationTest.php` (всего **291/291 за 32.94 сек**, 1060 assertions): inapp=true создаёт row + поля + payload / inapp=false не создаёт / schema-default ставит row / 2 user'а с inapp=true оба получают / inactive не получает / другой тенант не получает (RLS изоляция) / Биз-19 дубль не дублирует / повторный vid не дублирует / inapp+email=true создаёт 1 row + 1 email / payload содержит deal_id для deep-link / `notifyInApp` напрямую с reminder создаёт row. **(5) Quirk** — Write tool с относительным путём `app/tests/...` создал файлы в `app/app/tests/...` (CWD дрейфонул на `/c/моя/.../app/app`); файлы перемещены вручную, пустые директории удалены через rmdir (rm -rf не пройден permissions). **(6) IDE-helper регенерирован** для нового InAppNotification. **PHPStan baseline регенерирован** (1 «nullsafe.neverNull» error на `$deal->project?->name` подавлен через baseline). **Производственные TODO остаточные:** этап 2b (API + UI bell), этапы 3-6; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** Pint+PHPStan passed (baseline регенерирован); **Pest 291/291 за 32.94 сек** (+11 от 280, 1060 assertions); frontend нетронут — Vitest/build не нужны. v1.65→v1.66.* *CLAUDE.md v1.65 от 09.05.2026. Изменения v1.65: **P0 этап 1 — NotificationService + new_lead email** (старт closing TODO «Notification delivery» из карты остатка работы). Закрывает первый из 6 этапов плана P0 (notifications + reminders). **(1) `App\Services\NotificationService`** — центральный диспетчер. Константы 8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid/new_device_login/marketing) + 3 каналов (inapp/push/email) точно как в schema.sql:699 `users.notification_preferences` JSONB DEFAULT. Метод `notifyNewLead(Tenant, Deal)` — выбирает активных user'ов тенанта (is_active=true + deleted_at IS NULL) с включённым `notification_preferences.new_lead.email=true` и шлёт через `Mail::to(...)->send(NewLeadNotification)`. Throwable из Mail-фасада ловится → Log::warning (отказ канала не должен валить транзакцию webhook'а). **PHP-фильтр** prefs (не JSONB-запрос) — список получателей <50 на тенант, не critical-path. **(2) `App\Mail\NewLeadNotification`** — Mailable с (User $manager, Deal $deal, Tenant $tenant). Subject `«Лидерра. Новый лид — {project_name}»` с fallback project=`'Без проекта'` если relation не загружен. **`resources/views/emails/new_lead.blade.php`** — HTML-письмо в Forest-палитре (#0F6E56 primary, #F6F3EC ivory) с таблицей phone/contact_name/received_at (TZ конвертирована в `manager->timezone ?? 'Europe/Moscow'`)/deal_id. **(3) Интеграция `ProcessWebhookJob::chargeNewLead`** — после ActivityLog::create вызов `app(NotificationService::class)->notifyNewLead($tenant, $deal)`. `$deal->setRelation('project', $project)` чтобы Mailable не делал лишний SELECT. NotifyNewLead вне DB::transaction в смысле что ошибка отправки уже вне транзакции — но DB::transaction обёртка сейчас покрывает и notify-вызов; на prod надо или вынести notify ПОСЛЕ DB::transaction, или `Mail::queue` (async через worker). На MVP — sync через ::send (детерминированно для тестов). **(4) Pest +11** в `tests/Feature/Notifications/NewLeadNotificationTest.php`(всего **280/280 за 31.27 сек**, 1029 assertions): Mail::fake() / 1 user с email=true получает / user с email=false не получает / schema-default (.email=false) не шлёт / 2 user'а с email=true получают оба, 3-й с email=false не получает / inactive user с email=true не получает / soft-deleted user не получает / user другого тенанта не получает (изоляция) / Биз-19 дубль не шлёт повторное уведомление / повторный vid (idempotent UPDATE) не шлёт повторно / balance=0 (RejectedDealsLog) не шлёт / subject содержит project_name «Caranga». **(5) IDE-helper** регенерирован (`ide-helper:models -W -M -N`) — добавил @mixin docblocks 4 моделям (ImpersonationToken/SaasAdminAuditLog/SystemSetting/UserRecoveryCode), которые ранее без них работали через baseline-ignore'ы. **PHPStan baseline регенерирован** — 138 «ignore.unmatched» errors схлопнулись (новые docblocks резолвят property access напрямую, baseline-патч больше не нужен). **Производственные TODO остаточные:** этапы 2–6 P0 (in_app_notifications + UI bell, NotificationsTab fix под schema, reminders backend+frontend, остальные 4 email-события); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** Pint+PHPStan passed (baseline регенерирован); **Pest 280/280 за 31.27 сек** (+11 от 269, 1029 assertions); frontend нетронут — Vitest/build не нужны. Реестр без изменений (notifications не было в открытых вопросах). v1.64→v1.65.* diff --git a/app/app/Http/Controllers/Api/InAppNotificationController.php b/app/app/Http/Controllers/Api/InAppNotificationController.php new file mode 100644 index 00000000..b6872f2b --- /dev/null +++ b/app/app/Http/Controllers/Api/InAppNotificationController.php @@ -0,0 +1,168 @@ +id)` поверх RLS. + */ +class InAppNotificationController extends Controller +{ + /** + * GET /api/notifications?unread_only=1&limit=50. + * + * Возвращает: {items, unread_count, total}. + * Лимит: 1..100 (default 50). Сортировка: created_at DESC. + */ + public function index(Request $request): JsonResponse + { + $validated = $request->validate([ + 'unread_only' => 'nullable|in:0,1,true,false', + 'limit' => 'nullable|integer|min:1|max:100', + ]); + + /** @var User $user */ + $user = $request->user(); + $unreadOnly = filter_var($validated['unread_only'] ?? false, FILTER_VALIDATE_BOOLEAN); + $limit = (int) ($validated['limit'] ?? 50); + + return DB::transaction(function () use ($user, $unreadOnly, $limit): JsonResponse { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); + + $query = InAppNotification::query() + ->where('user_id', $user->id) + ->orderByDesc('created_at') + ->orderByDesc('id'); + + if ($unreadOnly) { + $query->whereNull('read_at'); + } + + $items = $query->limit($limit)->get(); + + $unreadCount = (int) InAppNotification::query() + ->where('user_id', $user->id) + ->whereNull('read_at') + ->count(); + + $total = (int) InAppNotification::query() + ->where('user_id', $user->id) + ->count(); + + return response()->json([ + 'items' => $items->map(fn (InAppNotification $n) => [ + 'id' => $n->id, + 'event' => $n->event, + 'title' => $n->title, + 'body' => $n->body, + 'deal_id' => $n->deal_id, + 'payload' => $n->payload, + 'read_at' => $n->read_at?->toIso8601String(), + 'created_at' => $n->created_at?->toIso8601String(), + ])->all(), + 'unread_count' => $unreadCount, + 'total' => $total, + ]); + }); + } + + /** + * PATCH /api/notifications/{id}/read. + * + * Идемпотентно: повторный вызов не меняет read_at (already-read NO-OP). + * 404 если notification не принадлежит user'у. + */ + public function markRead(Request $request, int $id): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + return DB::transaction(function () use ($user, $id): JsonResponse { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); + + $notif = InAppNotification::query() + ->where('id', $id) + ->where('user_id', $user->id) + ->first(); + + if ($notif === null) { + return response()->json(['message' => 'Уведомление не найдено.'], 404); + } + + if ($notif->read_at === null) { + $notif->update(['read_at' => now()]); + } + + return response()->json([ + 'id' => $notif->id, + 'read_at' => $notif->read_at?->toIso8601String(), + ]); + }); + } + + /** + * POST /api/notifications/mark-all-read — массовое чтение. + * + * Bulk-update read_at=NOW() для всех непрочитанных user'а. + * Возвращает количество обновлённых. + */ + public function markAllRead(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + return DB::transaction(function () use ($user): JsonResponse { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); + + $updated = InAppNotification::query() + ->where('user_id', $user->id) + ->whereNull('read_at') + ->update(['read_at' => now()]); + + return response()->json([ + 'updated' => $updated, + ]); + }); + } + + /** + * DELETE /api/notifications/{id} — удаление одного уведомления. + * + * Hard-delete (нет soft-delete для in-app — уведомления накапливаются + * быстро, ретеншн 90 дней через cleanup-job). + */ + public function destroy(Request $request, int $id): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + return DB::transaction(function () use ($user, $id): JsonResponse { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); + + $deleted = InAppNotification::query() + ->where('id', $id) + ->where('user_id', $user->id) + ->delete(); + + if ($deleted === 0) { + return response()->json(['message' => 'Уведомление не найдено.'], 404); + } + + return response()->json(['message' => 'Удалено.']); + }); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 2d6b7d1b..536f554e 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -570,6 +570,48 @@ parameters: count: 3 path: tests/Feature/LookupsTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 21 + path: tests/Feature/Notifications/InAppNotificationApiTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 14 + path: tests/Feature/Notifications/InAppNotificationApiTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Notifications/InAppNotificationApiTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Notifications/InAppNotificationApiTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/Notifications/InAppNotificationApiTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#' + identifier: method.notFound + count: 4 + path: tests/Feature/Notifications/InAppNotificationApiTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Notifications/InAppNotificationApiTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$partitionsBefore\.$#' identifier: property.notFound diff --git a/app/resources/js/api/notifications.ts b/app/resources/js/api/notifications.ts new file mode 100644 index 00000000..120d5cf7 --- /dev/null +++ b/app/resources/js/api/notifications.ts @@ -0,0 +1,67 @@ +import { apiClient, ensureCsrfCookie } from './client'; + +/** + * In-app уведомления для bell-icon UI (schema v8.10). + * + * Все endpoint'ы под Sanctum SPA auth — для незалогиненных вернётся 401. + * Mutating-вызовы (mark-read/mark-all-read/destroy) делают ensureCsrfCookie(). + */ + +export type NotificationEvent = + | 'new_lead' + | 'reminder' + | 'low_balance' + | 'zero_balance' + | 'topup_success' + | 'invoice_paid' + | 'new_device_login' + | 'marketing'; + +export interface ApiInAppNotification { + id: number; + event: NotificationEvent; + title: string; + body: string | null; + deal_id: number | null; + payload: Record; + read_at: string | null; + created_at: string | null; +} + +export interface ListNotificationsResponse { + items: ApiInAppNotification[]; + unread_count: number; + total: number; +} + +export interface ListNotificationsParams { + unreadOnly?: boolean; + limit?: number; +} + +export async function listNotifications(params: ListNotificationsParams = {}): Promise { + const { data } = await apiClient.get('/api/notifications', { + params: { + unread_only: params.unreadOnly ? 1 : undefined, + limit: params.limit, + }, + }); + return data; +} + +export async function markNotificationRead(id: number): Promise<{ id: number; read_at: string | null }> { + await ensureCsrfCookie(); + const { data } = await apiClient.patch<{ id: number; read_at: string | null }>(`/api/notifications/${id}/read`); + return data; +} + +export async function markAllNotificationsRead(): Promise<{ updated: number }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ updated: number }>('/api/notifications/mark-all-read'); + return data; +} + +export async function deleteNotification(id: number): Promise { + await ensureCsrfCookie(); + await apiClient.delete(`/api/notifications/${id}`); +} diff --git a/app/resources/js/layouts/AppLayout.vue b/app/resources/js/layouts/AppLayout.vue index 40416cd5..1b2b0df8 100644 --- a/app/resources/js/layouts/AppLayout.vue +++ b/app/resources/js/layouts/AppLayout.vue @@ -15,7 +15,9 @@ * Иконки — mdi (Vuetify default), визуально близки к lucide из дизайна. */ import { useAuthStore } from '../stores/auth'; -import { computed, ref } from 'vue'; +import { useNotificationsStore } from '../stores/notifications'; +import { usePolling } from '../composables/usePolling'; +import { computed, onMounted, ref } from 'vue'; import { RouterView, useRoute, useRouter } from 'vue-router'; interface NavItem { @@ -58,8 +60,58 @@ const navGroups: NavGroup[] = [ const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); +const notifications = useNotificationsStore(); const drawerOpen = ref(true); +const unreadDisplay = computed(() => { + if (notifications.unreadCount === 0) return ''; + if (notifications.unreadCount > 99) return '99+'; + return String(notifications.unreadCount); +}); + +function eventIcon(event: string): string { + const map: Record = { + new_lead: 'mdi-account-plus-outline', + reminder: 'mdi-clock-outline', + low_balance: 'mdi-wallet-outline', + zero_balance: 'mdi-alert-circle-outline', + topup_success: 'mdi-cash-plus', + invoice_paid: 'mdi-receipt-text-check-outline', + new_device_login: 'mdi-shield-account-outline', + marketing: 'mdi-bullhorn-outline', + }; + return map[event] ?? 'mdi-bell-outline'; +} + +function formatRelative(iso: string | null): string { + if (!iso) return ''; + const ms = Date.now() - new Date(iso).getTime(); + const min = Math.floor(ms / 60_000); + if (min < 1) return 'только что'; + if (min < 60) return `${min} мин назад`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr} ч назад`; + const days = Math.floor(hr / 24); + return `${days} д назад`; +} + +async function handleNotificationClick(id: number, dealId: number | null): Promise { + await notifications.markRead(id); + if (dealId !== null) { + // На MVP открываем DealsView (без deep-link на конкретный drawer); + // полный deep-link на конкретную сделку — отдельный коммит. + await router.push('/deals'); + } +} + +async function loadNotifications(): Promise { + if (!auth.user) return; + await notifications.load(10); +} + +onMounted(loadNotifications); +usePolling(loadNotifications, { intervalMs: 30_000, enabled: true }); + const currentPageTitle = computed(() => { const all = navGroups.flatMap((g) => g.items); return all.find((i) => i.to === route.path)?.title ?? 'Страница'; @@ -155,10 +207,67 @@ async function handleLogout() { - - mdi-bell-outline - + + + +
+ Уведомления + + Прочитать все + +
+ +
+ mdi-bell-off-outline +
Нет уведомлений
+
+ + + + {{ item.title }} + + {{ item.body }} + + + + +
+