Files
portal/app/resources/js/stores/notifications.ts
T
Дмитрий 508de4eaf3 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>
2026-05-09 11:27:57 +03:00

143 lines
4.4 KiB
TypeScript

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,
};
});