Уведомления: persistent + live (push-to-invalidate → pull)

Новая система уведомлений поверх готовой SSE-шины: события пишутся в БД,
живут 72 часа и чистятся (при чтении списка + фоновой задачей), всплывают
сверху экрана в момент прихода, доступны через колокольчик в правом верхнем
углу (бейдж непрочитанных + панель).

Типы: приглашение в группу (→ /group), старт/финиш партии участникам кроме
инициатора (→ /match/{id}). Титулы — готовый хелпер-задел (не подключён, т.к.
выдача титулов игрокам ещё не реализована).

Бэкенд: модель Notification + миграция 0008 (идемпотентная), notification_service,
notify.notifications_changed, роутер /api/notifications (GET + /read), триггеры
в groups/matches, фоновая чистка в lifespan, защита hub.publish от закрытого loop.
Фронт: useNotifications/useMarkNotificationsRead, NotificationBell/Panel/Toaster,
перекомпоновка top-bar, стили; useServerEvents знает тип notifications.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 15:16:40 +03:00
co-authored by Claude Opus 4.8
parent ef5c242e16
commit f4286ffa6c
20 changed files with 939 additions and 13 deletions
+116
View File
@@ -662,6 +662,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/notifications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My Notifications */
get: operations["my_notifications_api_notifications_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/notifications/read": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Mark Read */
post: operations["mark_read_api_notifications_read_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/login": {
parameters: {
query?: never;
@@ -1725,6 +1759,35 @@ export interface components {
/** Nickname */
nickname: string;
};
/** NotificationList */
NotificationList: {
/** Items */
items: components["schemas"]["NotificationRead"][];
/** Unread Count */
unread_count: number;
};
/** NotificationMarkRead */
NotificationMarkRead: {
/** Ids */
ids?: number[] | null;
};
/** NotificationRead */
NotificationRead: {
/** Id */
id: number;
/** Type */
type: string;
/** Title */
title: string;
/** Body */
body?: string | null;
/** Link */
link?: string | null;
/** Read At */
read_at?: string | null;
/** Created At */
created_at: string;
};
/** OkResponse */
OkResponse: {
/**
@@ -3232,6 +3295,59 @@ export interface operations {
};
};
};
my_notifications_api_notifications_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NotificationList"];
};
};
};
};
mark_read_api_notifications_read_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["NotificationMarkRead"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_login_api_admin_auth_login_post: {
parameters: {
query?: never;