feat(notifications): add navbar dropdown and sse client

This commit is contained in:
2026-04-25 11:29:53 +03:30
parent 441cc0c008
commit 2d903de97b
10 changed files with 1098 additions and 242 deletions

View File

@@ -1,4 +1,10 @@
import { API_BASE_URL } from "../config/constants"
import {
clearSessionTokens,
emitSessionChanged,
getAccessToken,
getRefreshToken,
} from "../lib/session"
let refreshRequest: Promise<string | null> | null = null
@@ -40,8 +46,7 @@ const normalizeJsonResponse = (response: Response) => {
}
const clearSessionAndRedirect = () => {
localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken")
clearSessionTokens()
if (window.location.pathname !== "/auth") {
window.location.href = "/auth"
}
@@ -58,7 +63,7 @@ const shouldAttemptRefresh = (endpoint: string) => {
}
const refreshAccessToken = async () => {
const refreshToken = localStorage.getItem("refreshToken")
const refreshToken = getRefreshToken()
if (!refreshToken) return null
if (!refreshRequest) {
@@ -87,6 +92,7 @@ const refreshAccessToken = async () => {
if (nextRefreshToken) {
localStorage.setItem("refreshToken", nextRefreshToken)
}
emitSessionChanged()
return nextAccessToken
})().finally(() => {
@@ -98,7 +104,7 @@ const refreshAccessToken = async () => {
}
export const authFetch = async (endpoint: string, options: RequestInit = {}, allowRetry = true): Promise<Response> => {
const token = localStorage.getItem("accessToken")
const token = getAccessToken()
const isFormData = options.body instanceof FormData
const headers: HeadersInit = {

108
src/api/notifications.ts Normal file
View File

@@ -0,0 +1,108 @@
import { API_BASE_URL } from "../config/constants"
import { authFetch } from "./client"
export type NotificationLevel = "info" | "success" | "warning" | "error"
export interface NotificationItem {
id: string
type: string
title: string
message: string
level: NotificationLevel
created_at: string
is_seen: boolean
delete_on_seen: boolean
action_url?: string | null
entity_type?: string | null
entity_id?: string | null
meta?: Record<string, unknown>
}
export interface NotificationsResponse {
count: number
unread_count: number
notifications: NotificationItem[]
}
export interface NotificationStreamTokenResponse {
token: string
expires_in: number
}
export interface NotificationFilters {
limit?: number
offset?: number
type?: string
}
const buildSearchParams = (filters: NotificationFilters = {}) => {
const searchParams = new URLSearchParams()
if (typeof filters.limit === "number") {
searchParams.set("limit", String(filters.limit))
}
if (typeof filters.offset === "number") {
searchParams.set("offset", String(filters.offset))
}
if (filters.type) {
searchParams.set("type", filters.type)
}
const query = searchParams.toString()
return query ? `?${query}` : ""
}
export const getNotifications = async (
filters: NotificationFilters = {},
): Promise<NotificationsResponse> => {
const response = await authFetch(`/api/notifications/list/${buildSearchParams(filters)}`)
if (!response.ok) {
throw new Error("Failed to load notifications")
}
return response.json()
}
export const markNotificationSeen = async (id: string) => {
const response = await authFetch("/api/notifications/seen/", {
method: "POST",
body: JSON.stringify({ id }),
})
if (!response.ok) {
throw new Error("Failed to mark notification as read")
}
return response.json()
}
export const deleteNotification = async (id: string) => {
const response = await authFetch(`/api/notifications/${id}/`, {
method: "DELETE",
})
if (!response.ok) {
throw new Error("Failed to delete notification")
}
return response.json()
}
export const markAllNotificationsRead = async (type?: string) => {
const response = await authFetch(`/api/notifications/seen/all/${buildSearchParams({ type })}`, {
method: "POST",
body: JSON.stringify(type ? { type } : {}),
})
if (!response.ok) {
throw new Error("Failed to mark all notifications as read")
}
return response.json()
}
export const issueNotificationStreamToken = async (): Promise<NotificationStreamTokenResponse> => {
const response = await authFetch("/api/notifications/stream-token/", {
method: "POST",
})
if (!response.ok) {
throw new Error("Failed to issue notification stream token")
}
return response.json()
}
export const buildNotificationStreamUrl = (token: string) => {
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}`
}