Files
portal/app/resources/js/api/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

68 lines
2.0 KiB
TypeScript

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