Уведомления: 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
+1
View File
@@ -16,6 +16,7 @@ export const qk = {
match: (id: number) => ["match", id] as const,
authConfig: ["authConfig"] as const,
invitations: ["invitations"] as const,
notifications: ["notifications"] as const,
devUsers: ["devUsers"] as const,
adminUsers: ["adminUsers"] as const,
adminGroups: ["adminGroups"] as const,
+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;
+17 -8
View File
@@ -3,6 +3,8 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMe } from "../hooks/auth";
import { useServerEvents } from "../hooks/useServerEvents";
import { BottomBar } from "./BottomBar";
import { NotificationBell } from "./NotificationBell";
import { NotificationToaster } from "./NotificationToaster";
const TITLES: Record<string, string> = {
"/": "Forbidden Stars",
@@ -26,16 +28,23 @@ export function AppShell() {
return (
<div className="app-shell">
<header className="top-bar">
{isHome ? (
<span className="title">{title}</span>
) : (
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
← Назад
</button>
)}
<span className="title small muted">{!isHome ? title : ""}</span>
<div className="top-bar-left">
{isHome ? (
<span className="title">{title}</span>
) : (
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
← Назад
</button>
)}
</div>
<span className="title small muted top-bar-center">{!isHome ? title : ""}</span>
<div className="top-bar-right">
<NotificationBell enabled={!!me} />
</div>
</header>
<NotificationToaster enabled={!!me} />
<main className="app-main">
<Outlet />
</main>
@@ -0,0 +1,42 @@
import { Bell } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useMarkNotificationsRead, useNotifications } from "../hooks/notifications";
import { NotificationPanel } from "./NotificationPanel";
/** Колокольчик в правом верхнем углу: бейдж непрочитанных + выпадающая панель. */
export function NotificationBell({ enabled }: { enabled: boolean }) {
const { data } = useNotifications(enabled);
const markRead = useMarkNotificationsRead();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const unread = data?.unread_count ?? 0;
// Закрытие панели по клику вне неё.
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
if (!enabled) return null;
const toggle = () => {
const next = !open;
setOpen(next);
if (next && unread > 0) markRead.mutate(undefined); // открыли — гасим бейдж
};
return (
<div className="notif-bell-wrap" ref={ref}>
<button className="notif-bell" aria-label="Уведомления" onClick={toggle}>
<Bell size={20} />
{unread > 0 && <span className="notif-badge">{unread > 99 ? "99+" : unread}</span>}
</button>
{open && <NotificationPanel items={data?.items ?? []} onClose={() => setOpen(false)} />}
</div>
);
}
@@ -0,0 +1,43 @@
import { useNavigate } from "react-router-dom";
import { formatTime } from "../domain/format";
import type { Notification } from "../hooks/notifications";
/** Выпадающий список уведомлений под колокольчиком. Клик по строке ведёт по ссылке. */
export function NotificationPanel({
items,
onClose,
}: {
items: Notification[];
onClose: () => void;
}) {
const navigate = useNavigate();
const go = (n: Notification) => {
onClose();
if (n.link) navigate(n.link);
};
return (
<div className="notif-panel">
<div className="notif-panel-head">Уведомления</div>
{items.length === 0 ? (
<div className="notif-empty muted small">Уведомлений нет</div>
) : (
<div className="notif-list">
{items.map((n) => (
<button
key={n.id}
className={`notif-item${n.read_at ? "" : " unread"}`}
onClick={() => go(n)}
>
<div className="notif-item-title">{n.title}</div>
{n.body && <div className="notif-item-body muted small">{n.body}</div>}
<div className="notif-item-time muted small">{formatTime(n.created_at)}</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNotifications, type Notification } from "../hooks/notifications";
const TOAST_TTL_MS = 5000;
const MAX_VISIBLE = 4;
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */
export function NotificationToaster({ enabled }: { enabled: boolean }) {
const { data } = useNotifications(enabled);
const navigate = useNavigate();
const seen = useRef<Set<number> | null>(null);
const [toasts, setToasts] = useState<Notification[]>([]);
useEffect(() => {
if (!data) return;
const items = data.items;
if (seen.current === null) {
seen.current = new Set(items.map((n) => n.id)); // первая загрузка — запомнить, не всплывать
return;
}
const fresh = items.filter((n) => !seen.current!.has(n.id));
if (fresh.length === 0) return;
fresh.forEach((n) => seen.current!.add(n.id));
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE));
fresh.forEach((n) => {
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS);
});
}, [data]);
if (!enabled || toasts.length === 0) return null;
const click = (n: Notification) => {
setToasts((prev) => prev.filter((t) => t.id !== n.id));
if (n.link) navigate(n.link);
};
return (
<div className="toast-top-wrap">
{toasts.map((n) => (
<button key={n.id} className="toast-top" onClick={() => click(n)}>
<div className="toast-top-title">{n.title}</div>
{n.body && <div className="toast-top-body">{n.body}</div>}
</button>
))}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { components } from "../api/schema";
export type Notification = components["schemas"]["NotificationRead"];
export function useNotifications(enabled: boolean) {
return useQuery({
queryKey: qk.notifications,
queryFn: async () => unwrap(await api.GET("/api/notifications")),
enabled,
});
}
export function useMarkNotificationsRead() {
const qc = useQueryClient();
return useMutation({
// ids не задаём → сервер отметит прочитанными все непрочитанные.
mutationFn: async (ids?: number[]) =>
unwrap(await api.POST("/api/notifications/read", { body: { ids: ids ?? null } })),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.notifications }),
});
}
+3 -1
View File
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { qk } from "../api/queryKeys";
interface ServerEvent {
type: "match" | "group" | "invitations";
type: "match" | "group" | "invitations" | "notifications";
match_id?: number;
group_id?: number;
}
@@ -30,6 +30,8 @@ export function useServerEvents(enabled: boolean) {
}
if (ev.type === "invitations") {
qc.invalidateQueries({ queryKey: qk.invitations });
} else if (ev.type === "notifications") {
qc.invalidateQueries({ queryKey: qk.notifications });
} else if (ev.type === "match") {
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
if (ev.group_id != null) {
+107
View File
@@ -107,3 +107,110 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
text-align: center;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
.top-bar-left { display: flex; align-items: center; min-width: 0; }
.top-bar-center {
flex: 1;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 8px;
}
.top-bar-right { display: flex; align-items: center; }
.notif-bell-wrap { position: relative; }
.notif-bell {
position: relative;
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: none;
background: none;
color: var(--text);
border-radius: 50%;
}
.notif-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.notif-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(320px, 86vw);
max-height: 70vh;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
z-index: 50;
}
.notif-panel-head {
padding: 10px 14px;
font-weight: 700;
border-bottom: 1px solid var(--border);
}
.notif-empty { padding: 18px 14px; text-align: center; }
.notif-list { display: flex; flex-direction: column; }
.notif-item {
display: block;
width: 100%;
text-align: left;
padding: 10px 14px;
background: none;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text);
}
.notif-item:last-child { border-bottom: none; }
.notif-item.unread { background: var(--surface-2); }
.notif-item-title { font-weight: 600; }
.notif-item-body { margin-top: 2px; }
.notif-item-time { margin-top: 4px; }
/* Всплывашки сверху экрана в момент прихода */
.toast-top-wrap {
position: fixed;
top: calc(var(--safe-top) + 10px);
left: 50%;
transform: translateX(-50%);
width: min(var(--app-max-w), 94vw);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
pointer-events: none;
}
.toast-top {
pointer-events: auto;
display: block;
width: 100%;
text-align: left;
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
color: var(--text);
padding: 10px 14px;
border-radius: var(--radius-sm);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.toast-top-title { font-weight: 700; }
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }