- Introduced a new support ticket system allowing users to submit bug reports and role requests. - Implemented endpoints for creating, updating, and managing support tickets, including file attachments. - Enhanced Telegram bot integration to handle role requests directly within the bot, enabling admins to approve or reject requests without accessing the website. - Updated database schema to include support ticket entities and their relationships. - Improved API documentation to reflect new support ticket endpoints and their usage. - Added necessary localization for support ticket features in both Russian and English.
127 lines
4.0 KiB
TypeScript
127 lines
4.0 KiB
TypeScript
import type { ApiError } from './types'
|
|
|
|
let accessToken: string | null = null
|
|
let refreshInFlight: Promise<boolean> | null = null
|
|
let onUnauthorized: (() => void) | null = null
|
|
|
|
export function setAccessToken(token: string | null) {
|
|
accessToken = token
|
|
}
|
|
|
|
export function getAccessToken() {
|
|
return accessToken
|
|
}
|
|
|
|
/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */
|
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
|
onUnauthorized = handler
|
|
}
|
|
|
|
type RequestOptions = {
|
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
body?: unknown
|
|
/** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */
|
|
skipRefresh?: boolean
|
|
}
|
|
|
|
async function refreshAccessToken(): Promise<boolean> {
|
|
if (!refreshInFlight) {
|
|
refreshInFlight = (async () => {
|
|
try {
|
|
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
|
if (!response.ok) return false
|
|
const data = (await response.json()) as { accessToken: string }
|
|
setAccessToken(data.accessToken)
|
|
return true
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
refreshInFlight = null
|
|
}
|
|
})()
|
|
}
|
|
return refreshInFlight
|
|
}
|
|
|
|
export class HttpError extends Error implements ApiError {
|
|
title: string
|
|
detail: string
|
|
status: number
|
|
|
|
constructor(problem: Partial<ApiError>, status: number) {
|
|
super(problem.detail ?? problem.title ?? `HTTP ${status}`)
|
|
this.title = problem.title ?? 'Error'
|
|
this.detail = problem.detail ?? this.message
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
async function parseError(response: Response): Promise<HttpError> {
|
|
try {
|
|
const problem = (await response.json()) as Partial<ApiError>
|
|
return new HttpError(problem, response.status)
|
|
} catch {
|
|
return new HttpError({ title: response.statusText }, response.status)
|
|
}
|
|
}
|
|
|
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const headers: Record<string, string> = {}
|
|
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
|
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
|
|
|
const response = await fetch(`/api${path}`, {
|
|
method: options.method ?? 'GET',
|
|
headers,
|
|
credentials: 'include',
|
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
})
|
|
|
|
if (response.status === 401 && !options.skipRefresh) {
|
|
const refreshed = await refreshAccessToken()
|
|
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
|
|
onUnauthorized?.()
|
|
throw await parseError(response)
|
|
}
|
|
|
|
if (!response.ok) throw await parseError(response)
|
|
|
|
if (response.status === 204) return undefined as T
|
|
|
|
const text = await response.text()
|
|
return (text ? JSON.parse(text) : undefined) as T
|
|
}
|
|
|
|
type UploadOptions = {
|
|
method?: 'POST' | 'PUT'
|
|
skipRefresh?: boolean
|
|
}
|
|
|
|
/** Как apiRequest, но для multipart/form-data (вложения к тикетам) — без JSON.stringify и
|
|
* без Content-Type (браузер сам проставляет boundary). */
|
|
export async function apiUpload<T>(path: string, formData: FormData, options: UploadOptions = {}): Promise<T> {
|
|
const headers: Record<string, string> = {}
|
|
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
|
|
|
const response = await fetch(`/api${path}`, {
|
|
method: options.method ?? 'POST',
|
|
headers,
|
|
credentials: 'include',
|
|
body: formData,
|
|
})
|
|
|
|
if (response.status === 401 && !options.skipRefresh) {
|
|
const refreshed = await refreshAccessToken()
|
|
if (refreshed) return apiUpload<T>(path, formData, { ...options, skipRefresh: true })
|
|
onUnauthorized?.()
|
|
throw await parseError(response)
|
|
}
|
|
|
|
if (!response.ok) throw await parseError(response)
|
|
|
|
if (response.status === 204) return undefined as T
|
|
|
|
const text = await response.text()
|
|
return (text ? JSON.parse(text) : undefined) as T
|
|
}
|