Phase 5: Notifications — WebSocket, APScheduler, AI tool, health review

Backend:
- Notification model + Alembic migration
- Notification service: CRUD, mark read, unread count, pending scheduled
- WebSocket manager singleton for real-time push
- WebSocket endpoint /ws/notifications with JWT auth via query param
- APScheduler integration: periodic notification sender (every 60s),
  daily proactive health review job (8 AM)
- AI tool: schedule_notification (immediate or scheduled)
- Health review worker: analyzes user memory via Claude, creates
  ai_generated notifications with WebSocket push

Frontend:
- Notification API client + Zustand store
- WebSocket hook with auto-reconnect (exponential backoff)
- Notification bell in header with unread count badge + dropdown
- Notifications page with type badges, mark read, mark all read
- WebSocket initialized in AppLayout for app-wide real-time updates
- Enabled notifications nav in sidebar
- English + Russian translations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:57:25 +03:00
parent 8b8fe916f0
commit ada7e82961
30 changed files with 1074 additions and 4 deletions

View File

@@ -90,6 +90,19 @@
"subtitle": "This context is added to all your AI conversations",
"placeholder": "Add personal information that the AI should know about you..."
},
"notifications": {
"title": "Notifications",
"no_notifications": "No notifications yet.",
"mark_all_read": "Mark all read",
"mark_read": "Mark as read",
"view_all": "View all notifications",
"types": {
"reminder": "Reminder",
"alert": "Alert",
"info": "Info",
"ai_generated": "AI Generated"
}
},
"documents": {
"upload": "Upload",
"drop_or_click": "Drop a file here or click to browse",

View File

@@ -90,6 +90,19 @@
"subtitle": "Этот контекст добавляется ко всем вашим разговорам с ИИ",
"placeholder": "Добавьте личную информацию, которую ИИ должен знать о вас..."
},
"notifications": {
"title": "Уведомления",
"no_notifications": "Уведомлений пока нет.",
"mark_all_read": "Отметить все как прочитанные",
"mark_read": "Отметить как прочитанное",
"view_all": "Все уведомления",
"types": {
"reminder": "Напоминание",
"alert": "Оповещение",
"info": "Информация",
"ai_generated": "От ИИ"
}
},
"documents": {
"upload": "Загрузить",
"drop_or_click": "Перетащите файл или нажмите для выбора",

View File

@@ -0,0 +1,44 @@
import api from "./client";
export interface Notification {
id: string;
user_id: string;
title: string;
body: string;
type: string;
channel: string;
status: string;
scheduled_at: string | null;
sent_at: string | null;
read_at: string | null;
metadata: Record<string, unknown> | null;
created_at: string;
}
export interface NotificationListResponse {
notifications: Notification[];
unread_count: number;
}
export async function getNotifications(params?: {
status?: string;
limit?: number;
offset?: number;
}): Promise<NotificationListResponse> {
const { data } = await api.get<NotificationListResponse>("/notifications/", { params });
return data;
}
export async function getUnreadCount(): Promise<number> {
const { data } = await api.get<{ count: number }>("/notifications/unread-count");
return data.count;
}
export async function markAsRead(notificationId: string): Promise<Notification> {
const { data } = await api.patch<Notification>(`/notifications/${notificationId}/read`);
return data;
}
export async function markAllRead(): Promise<void> {
await api.post("/notifications/mark-all-read");
}

View File

@@ -1,8 +1,11 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
import { useNotificationsWS } from "@/hooks/use-notifications-ws";
export function AppLayout() {
useNotificationsWS();
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />

View File

@@ -4,6 +4,7 @@ import { Menu, Sun, Moon, LogOut, User } from "lucide-react";
import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store";
import { LanguageToggle } from "@/components/shared/language-toggle";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { logout as logoutApi } from "@/api/auth";
export function Header() {
@@ -41,6 +42,8 @@ export function Header() {
<LanguageToggle />
<NotificationBell />
<button
onClick={toggleTheme}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"

View File

@@ -21,7 +21,7 @@ const navItems = [
{ key: "personal_context", to: "/profile/context", icon: BookOpen, enabled: true, end: true },
{ key: "documents", to: "/documents", icon: FileText, enabled: true, end: true },
{ key: "memory", to: "/memory", icon: Brain, enabled: true, end: true },
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false, end: true },
{ key: "notifications", to: "/notifications", icon: Bell, enabled: true, end: true },
];
const adminItems = [

View File

@@ -0,0 +1,93 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Bell, Check } from "lucide-react";
import { useNotificationStore } from "@/stores/notification-store";
import { markAllRead as markAllReadApi } from "@/api/notifications";
import { cn } from "@/lib/utils";
export function NotificationBell() {
const { t } = useTranslation();
const navigate = useNavigate();
const { notifications, unreadCount, markAllRead } = useNotificationStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleMarkAllRead = async () => {
await markAllReadApi();
markAllRead();
};
const recent = notifications.slice(0, 5);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="relative rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-80 rounded-xl border bg-card shadow-lg">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-semibold">{t("notifications.title")}</span>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Check className="h-3 w-3" /> {t("notifications.mark_all_read")}
</button>
)}
</div>
<div className="max-h-72 overflow-y-auto">
{recent.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
{t("notifications.no_notifications")}
</p>
) : (
recent.map((n) => (
<div
key={n.id}
className={cn(
"border-b px-4 py-3 last:border-0",
!n.read_at && "bg-primary/5"
)}
>
<p className="text-sm font-medium">{n.title}</p>
<p className="text-xs text-muted-foreground line-clamp-2">{n.body}</p>
</div>
))
)}
</div>
<div className="border-t px-4 py-2">
<button
onClick={() => { navigate("/notifications"); setOpen(false); }}
className="w-full text-center text-xs text-primary hover:underline"
>
{t("notifications.view_all")}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { useNotificationStore } from "@/stores/notification-store";
export function useNotificationsWS() {
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { setUnreadCount, addNotification } = useNotificationStore();
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectDelay = useRef(1000);
useEffect(() => {
if (!isAuthenticated || !accessToken) return;
let mounted = true;
function connect() {
if (!mounted) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${protocol}//${window.location.host}/api/v1/ws/notifications?token=${accessToken}`
);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
reconnectDelay.current = 1000;
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "unread_count") {
setUnreadCount(data.count);
} else if (data.type === "new_notification") {
addNotification(data.notification);
}
} catch {
// ignore parse errors
}
};
ws.onclose = () => {
setIsConnected(false);
if (mounted) {
setTimeout(connect, reconnectDelay.current);
reconnectDelay.current = Math.min(reconnectDelay.current * 2, 30000);
}
};
ws.onerror = () => {
ws.close();
};
}
connect();
return () => {
mounted = false;
wsRef.current?.close();
};
}, [isAuthenticated, accessToken, setUnreadCount, addNotification]);
return { isConnected };
}

View File

@@ -0,0 +1,96 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { getNotifications, markAsRead, markAllRead as markAllReadApi } from "@/api/notifications";
import { useNotificationStore } from "@/stores/notification-store";
import { Check, Bell } from "lucide-react";
import { cn } from "@/lib/utils";
const typeColors: Record<string, string> = {
reminder: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
alert: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
info: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
ai_generated: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
};
export function NotificationsPage() {
const { t } = useTranslation();
const { notifications, setNotifications, setUnreadCount, markRead, markAllRead } = useNotificationStore();
const { data } = useQuery({
queryKey: ["notifications"],
queryFn: () => getNotifications({ limit: 100 }),
});
useEffect(() => {
if (data) {
setNotifications(data.notifications);
setUnreadCount(data.unread_count);
}
}, [data, setNotifications, setUnreadCount]);
const handleMarkRead = async (id: string) => {
await markAsRead(id);
markRead(id);
};
const handleMarkAllRead = async () => {
await markAllReadApi();
markAllRead();
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t("notifications.title")}</h1>
<button
onClick={handleMarkAllRead}
className="inline-flex h-9 items-center gap-2 rounded-md border px-4 text-sm font-medium hover:bg-accent transition-colors"
>
<Check className="h-4 w-4" /> {t("notifications.mark_all_read")}
</button>
</div>
{notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Bell className="h-12 w-12 mb-4 opacity-30" />
<p>{t("notifications.no_notifications")}</p>
</div>
) : (
<div className="space-y-2">
{notifications.map((n) => (
<div
key={n.id}
className={cn(
"flex items-start gap-4 rounded-lg border bg-card p-4 transition-colors",
!n.read_at && "bg-primary/5 border-primary/20"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium">{n.title}</p>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", typeColors[n.type] || typeColors.info)}>
{t(`notifications.types.${n.type}`)}
</span>
</div>
<p className="text-sm text-muted-foreground">{n.body}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(n.created_at).toLocaleString()}
</p>
</div>
{!n.read_at && (
<button
onClick={() => handleMarkRead(n.id)}
className="shrink-0 rounded p-2 hover:bg-accent transition-colors"
title={t("notifications.mark_read")}
>
<Check className="h-4 w-4" />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { AdminContextPage } from "@/pages/admin/context";
import { AdminSkillsPage } from "@/pages/admin/skills";
import { DocumentsPage } from "@/pages/documents";
import { MemoryPage } from "@/pages/memory";
import { NotificationsPage } from "@/pages/notifications";
import { NotFoundPage } from "@/pages/not-found";
export const router = createBrowserRouter([
@@ -33,6 +34,7 @@ export const router = createBrowserRouter([
{ path: "chat/:chatId", element: <ChatPage /> },
{ path: "documents", element: <DocumentsPage /> },
{ path: "memory", element: <MemoryPage /> },
{ path: "notifications", element: <NotificationsPage /> },
{ path: "skills", element: <SkillsPage /> },
{ path: "profile/context", element: <PersonalContextPage /> },
{ path: "admin/context", element: <AdminContextPage /> },

View File

@@ -0,0 +1,40 @@
import { create } from "zustand";
import type { Notification } from "@/api/notifications";
interface NotificationState {
notifications: Notification[];
unreadCount: number;
setNotifications: (notifications: Notification[]) => void;
setUnreadCount: (count: number) => void;
addNotification: (notification: Notification) => void;
markRead: (id: string) => void;
markAllRead: () => void;
}
export const useNotificationStore = create<NotificationState>()((set) => ({
notifications: [],
unreadCount: 0,
setNotifications: (notifications) => set({ notifications }),
setUnreadCount: (count) => set({ unreadCount: count }),
addNotification: (notification) =>
set((s) => ({
notifications: [notification, ...s.notifications],
unreadCount: s.unreadCount + 1,
})),
markRead: (id) =>
set((s) => ({
notifications: s.notifications.map((n) =>
n.id === id ? { ...n, status: "read", read_at: new Date().toISOString() } : n
),
unreadCount: Math.max(0, s.unreadCount - 1),
})),
markAllRead: () =>
set((s) => ({
notifications: s.notifications.map((n) => ({
...n,
status: "read",
read_at: n.read_at || new Date().toISOString(),
})),
unreadCount: 0,
})),
}));