173 lines
4.7 KiB
TypeScript
173 lines
4.7 KiB
TypeScript
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)}`
|
|
}
|
|
|
|
const REPORT_EXPORT_DOWNLOAD_PATTERN = /\/api\/reports\/exports\/[^/]+\/download\/?$/
|
|
|
|
const toApiEndpoint = (actionUrl: string) => {
|
|
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
|
if (actionUrl.startsWith("http://") || actionUrl.startsWith("https://")) {
|
|
if (!actionUrl.startsWith(cleanBaseUrl)) {
|
|
return null
|
|
}
|
|
return actionUrl.slice(cleanBaseUrl.length) || "/"
|
|
}
|
|
return actionUrl.startsWith("/") ? actionUrl : `/${actionUrl}`
|
|
}
|
|
|
|
const getFilenameFromDisposition = (contentDisposition: string | null) => {
|
|
if (!contentDisposition) return null
|
|
|
|
const utfMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
|
if (utfMatch?.[1]) {
|
|
return decodeURIComponent(utfMatch[1])
|
|
}
|
|
|
|
const plainMatch = contentDisposition.match(/filename="?([^"]+)"?/i)
|
|
return plainMatch?.[1] || null
|
|
}
|
|
|
|
export const isReportExportDownloadUrl = (actionUrl?: string | null) => {
|
|
if (!actionUrl) return false
|
|
const endpoint = toApiEndpoint(actionUrl)
|
|
return !!endpoint && REPORT_EXPORT_DOWNLOAD_PATTERN.test(endpoint)
|
|
}
|
|
|
|
export const downloadNotificationFile = async (
|
|
actionUrl: string,
|
|
fallbackFilename?: string | null,
|
|
) => {
|
|
const endpoint = toApiEndpoint(actionUrl)
|
|
if (!endpoint) {
|
|
throw new Error("Unsupported download url")
|
|
}
|
|
|
|
const response = await authFetch(endpoint, {
|
|
method: "GET",
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to download file")
|
|
}
|
|
|
|
const blob = await response.blob()
|
|
const objectUrl = window.URL.createObjectURL(blob)
|
|
const filename =
|
|
getFilenameFromDisposition(response.headers.get("content-disposition")) ||
|
|
fallbackFilename ||
|
|
"download"
|
|
|
|
const anchor = document.createElement("a")
|
|
anchor.href = objectUrl
|
|
anchor.download = filename
|
|
document.body.appendChild(anchor)
|
|
anchor.click()
|
|
anchor.remove()
|
|
window.URL.revokeObjectURL(objectUrl)
|
|
}
|