508de4eaf3
Закрывает этап 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>
143 lines
4.4 KiB
TypeScript
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,
|
|
};
|
|
});
|