feat(notifications): add navbar dropdown and sse client
This commit is contained in:
@@ -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
108
src/api/notifications.ts
Normal 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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user