Files
SaaS-PDF/frontend/src/services/api.ts
Your Name 271674a9c5 feat: Complete admin dashboard overhaul with professional features
- Add Events Timeline tab showing chronological project activity
- Add Create User modal with email, password, plan, role selection
- Add Delete User button with confirmation dialog
- Add Plan and Role management dropdowns per user
- Add event type summary cards with color-coded icons
- Add period selector for events (7d, 14d, 30d, 90d)
- Add i18n translations for all new features (EN + AR)
- Add new API functions: createAdminUser, deleteAdminUser, updateAdminUserPlan, updateAdminUserRole, getProjectEvents
2026-04-01 00:27:27 +02:00

1026 lines
25 KiB
TypeScript

import axios, { type InternalAxiosRequestConfig } from 'axios';
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token';
let csrfRefreshPromise: Promise<string> | null = null;
function getCookieValue(name: string): string {
if (typeof document === 'undefined') {
return '';
}
const encodedName = `${encodeURIComponent(name)}=`;
const cookie = document.cookie
.split('; ')
.find((item) => item.startsWith(encodedName));
return cookie ? decodeURIComponent(cookie.slice(encodedName.length)) : '';
}
function shouldAttachCsrfToken(config: InternalAxiosRequestConfig): boolean {
const method = String(config.method || 'get').toUpperCase();
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return false;
}
const headers = config.headers ?? {};
if ('X-API-Key' in headers || 'x-api-key' in headers) {
return false;
}
return !String(config.url || '').includes('/auth/csrf');
}
function setRequestHeader(config: InternalAxiosRequestConfig, key: string, value: string) {
if (!config.headers) {
config.headers = new axios.AxiosHeaders();
}
if (typeof (config.headers as { set?: (header: string, headerValue: string) => void }).set === 'function') {
(config.headers as { set: (header: string, headerValue: string) => void }).set(key, value);
return;
}
(config.headers as Record<string, string>)[key] = value;
}
async function ensureCsrfToken(forceRefresh = false): Promise<string> {
const existingToken = getCookieValue(CSRF_COOKIE_NAME);
if (existingToken && !forceRefresh) {
return existingToken;
}
if (!csrfRefreshPromise) {
csrfRefreshPromise = csrfBootstrapClient
.get('/auth/csrf')
.then(() => getCookieValue(CSRF_COOKIE_NAME))
.finally(() => {
csrfRefreshPromise = null;
});
}
return csrfRefreshPromise;
}
function isCsrfFailure(status: number, bodyText: string): boolean {
if (status !== 403) {
return false;
}
const normalizedBody = bodyText.toLowerCase();
return normalizedBody.includes('csrf');
}
async function postAssistantStream(
payload: AssistantChatRequest,
csrfToken: string
): Promise<Response> {
const streamHeaders: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
};
if (csrfToken) {
streamHeaders[CSRF_HEADER_NAME] = csrfToken;
}
return fetch('/api/assistant/chat/stream', {
method: 'POST',
credentials: 'include',
headers: streamHeaders,
body: JSON.stringify(payload),
});
}
const csrfBootstrapClient = axios.create({
baseURL: '/api',
timeout: 15000,
withCredentials: true,
headers: {
Accept: 'application/json',
},
});
const api = axios.create({
baseURL: '/api',
timeout: 120000, // 2 minute timeout for file processing
withCredentials: true,
headers: {
Accept: 'application/json',
},
});
// Request interceptor for logging
api.interceptors.request.use(
async (config) => {
if (!shouldAttachCsrfToken(config)) {
return config;
}
const csrfToken = await ensureCsrfToken();
if (csrfToken) {
setRequestHeader(config, CSRF_HEADER_NAME, csrfToken);
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor — auto-retry once on CSRF failure
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response) {
// Auto-retry on CSRF token mismatch (session expired, cookie lost, etc.)
const originalRequest = error.config;
if (
!originalRequest._csrfRetried &&
isCsrfFailure(
error.response.status,
typeof error.response.data === 'string'
? error.response.data
: JSON.stringify(error.response.data ?? '')
)
) {
originalRequest._csrfRetried = true;
const freshToken = await ensureCsrfToken(true);
if (freshToken) {
setRequestHeader(originalRequest, CSRF_HEADER_NAME, freshToken);
}
return api(originalRequest);
}
if (error.response.status === 429) {
return Promise.reject(new Error('Too many requests. Please wait a moment and try again.'));
}
const responseData = error.response.data;
const message =
responseData?.error ||
responseData?.message ||
(typeof responseData === 'string' && responseData.trim()
? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
: null) ||
`Request failed (${error.response.status}).`;
return Promise.reject(new Error(message));
}
if (error.request) {
return Promise.reject(new Error('Network error. Please check your connection.'));
}
return Promise.reject(error);
}
);
// --- API Functions ---
export interface TaskResponse {
task_id: string;
message: string;
}
export interface TaskErrorPayload {
error_code?: string;
user_message?: string;
task_id?: string;
trace_id?: string;
message?: string;
error?: string;
detail?: string;
}
export interface TaskStatus {
task_id: string;
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE';
progress?: string;
result?: TaskResult;
error?: string | TaskErrorPayload;
}
export interface TaskResult {
status: 'completed' | 'failed';
download_url?: string;
filename?: string;
error?: string;
error_code?: string;
user_message?: string;
task_id?: string;
trace_id?: string;
original_size?: number;
compressed_size?: number;
reduction_percent?: number;
width?: number;
height?: number;
output_size?: number;
duration?: number;
fps?: number;
format?: string;
// Flowchart-specific fields
procedures?: Array<{ id: string; title: string; description: string; pages: number[]; step_count: number }>;
flowcharts?: Array<{ id: string; procedureId: string; title: string; steps: Array<{ id: string; type: string; title: string; description: string; connections: string[] }> }>;
pages?: Array<{ page: number; text: string }>;
procedures_count?: number;
total_pages?: number;
// OCR-specific fields
text?: string;
char_count?: number;
// AI PDF fields
reply?: string;
summary?: string;
translation?: string;
target_language?: string;
source_language?: string;
detected_source_language?: string;
provider?: string;
chunks_translated?: number;
pages_analyzed?: number;
// Table extraction fields
tables?: Array<{ page: number; table_index: number; headers: string[]; rows: string[][] }>;
tables_found?: number;
}
function isTaskErrorPayload(value: unknown): value is TaskErrorPayload {
return Boolean(value) && typeof value === 'object';
}
export function getTaskErrorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string' && error.trim()) {
return error.trim();
}
if (isTaskErrorPayload(error)) {
const candidates = [
error.user_message,
error.message,
error.error,
error.detail,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim();
}
}
}
return fallback;
}
export interface AuthUser {
id: number;
email: string;
plan: string;
role: 'user' | 'admin' | string;
is_allowlisted_admin?: boolean;
created_at: string;
}
interface AuthResponse {
message: string;
user: AuthUser;
}
interface AuthSessionResponse {
authenticated: boolean;
user: AuthUser | null;
}
interface HistoryResponse {
items: HistoryEntry[];
}
export interface HistoryEntry {
id: number;
tool: string;
original_filename: string | null;
output_filename: string | null;
status: 'completed' | 'failed' | string;
download_url: string | null;
metadata: Record<string, unknown>;
created_at: string;
}
export interface AssistantHistoryMessage {
role: 'user' | 'assistant';
content: string;
}
export interface AssistantChatRequest {
message: string;
session_id?: string;
fingerprint: string;
tool_slug?: string;
page_url?: string;
locale?: string;
history?: AssistantHistoryMessage[];
}
export interface AssistantChatResponse {
session_id: string;
reply: string;
stored: boolean;
}
interface AssistantStreamHandlers {
onSession?: (sessionId: string) => void;
onChunk?: (chunk: string) => void;
}
interface AssistantStreamEvent {
event: string;
data: Record<string, unknown>;
}
function parseAssistantStreamEvent(rawEvent: string): AssistantStreamEvent | null {
const lines = rawEvent.split(/\r?\n/);
let event = 'message';
const dataLines: string[] = [];
for (const line of lines) {
if (!line) {
continue;
}
if (line.startsWith('event:')) {
event = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
if (!dataLines.length) {
return null;
}
return {
event,
data: JSON.parse(dataLines.join('\n')) as Record<string, unknown>,
};
}
function normalizeStreamError(status: number, bodyText: string): Error {
if (!bodyText.trim()) {
return new Error(`Request failed (${status}).`);
}
try {
const parsed = JSON.parse(bodyText) as { error?: string; message?: string };
return new Error(parsed.error || parsed.message || `Request failed (${status}).`);
} catch {
return new Error(bodyText.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim());
}
}
/**
* Upload a file and start a processing task.
*/
export async function uploadFile(
endpoint: string,
file: File,
extraData?: Record<string, string>,
onProgress?: (percent: number) => void
): Promise<TaskResponse> {
const formData = new FormData();
formData.append('file', file);
if (extraData) {
Object.entries(extraData).forEach(([key, value]) => {
formData.append(key, value);
});
}
const response = await api.post<TaskResponse>(endpoint, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => {
if (event.total && onProgress) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
},
});
return response.data;
}
/**
* Upload multiple files and start a processing task.
*/
export async function uploadFiles(
endpoint: string,
files: File[],
fileField = 'files',
extraData?: Record<string, string>,
onProgress?: (percent: number) => void
): Promise<TaskResponse> {
const formData = new FormData();
files.forEach((file) => formData.append(fileField, file));
if (extraData) {
Object.entries(extraData).forEach(([key, value]) => {
formData.append(key, value);
});
}
const response = await api.post<TaskResponse>(endpoint, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => {
if (event.total && onProgress) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
},
});
return response.data;
}
/**
* Start a task endpoint that does not require file upload.
*/
export async function startTask(endpoint: string): Promise<TaskResponse> {
const response = await api.post<TaskResponse>(endpoint);
return response.data;
}
/**
* Create a new account and return the authenticated user.
*/
export async function registerUser(email: string, password: string): Promise<AuthUser> {
const response = await api.post<AuthResponse>('/auth/register', { email, password });
await ensureCsrfToken(true);
return response.data.user;
}
/**
* Sign in and return the authenticated user.
*/
export async function loginUser(email: string, password: string): Promise<AuthUser> {
const response = await api.post<AuthResponse>('/auth/login', { email, password });
await ensureCsrfToken(true);
return response.data.user;
}
/**
* End the current authenticated session.
*/
export async function logoutUser(): Promise<void> {
await api.post('/auth/logout');
await ensureCsrfToken(true);
}
/**
* Return the current authenticated user, if any.
*/
export async function getCurrentUser(): Promise<AuthUser | null> {
const response = await api.get<AuthSessionResponse>('/auth/me');
return response.data.user;
}
/**
* Return recent authenticated file history.
*/
export async function getHistory(limit = 50): Promise<HistoryEntry[]> {
const response = await api.get<HistoryResponse>('/history', {
params: { limit },
});
return response.data.items;
}
/**
* Poll task status.
*/
export async function getTaskStatus(taskId: string): Promise<TaskStatus> {
const response = await api.get<TaskStatus>(`/tasks/${taskId}/status`);
return response.data;
}
export function getApiClient() {
return api;
}
/**
* Send one message to the site assistant.
*/
export async function chatWithAssistant(
payload: AssistantChatRequest
): Promise<AssistantChatResponse> {
const response = await api.post<AssistantChatResponse>('/assistant/chat', payload);
return response.data;
}
/**
* Stream one assistant response incrementally over SSE.
*/
export async function streamAssistantChat(
payload: AssistantChatRequest,
handlers: AssistantStreamHandlers = {}
): Promise<AssistantChatResponse> {
let response = await postAssistantStream(payload, await ensureCsrfToken());
if (!response.ok) {
let bodyText = await response.text();
if (isCsrfFailure(response.status, bodyText)) {
response = await postAssistantStream(payload, await ensureCsrfToken(true));
if (!response.ok) {
bodyText = await response.text();
throw normalizeStreamError(response.status, bodyText);
}
} else {
throw normalizeStreamError(response.status, bodyText);
}
}
if (!response.body) {
throw new Error('Streaming is not supported by this browser.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalResponse: AssistantChatResponse | null = null;
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
let boundary = buffer.indexOf('\n\n');
while (boundary !== -1) {
const rawEvent = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const parsedEvent = parseAssistantStreamEvent(rawEvent);
if (parsedEvent?.event === 'session') {
const sessionId = parsedEvent.data.session_id;
if (typeof sessionId === 'string') {
handlers.onSession?.(sessionId);
}
}
if (parsedEvent?.event === 'chunk') {
const chunk = parsedEvent.data.content;
if (typeof chunk === 'string' && chunk) {
handlers.onChunk?.(chunk);
}
}
if (parsedEvent?.event === 'done') {
const sessionId = parsedEvent.data.session_id;
const reply = parsedEvent.data.reply;
const stored = parsedEvent.data.stored;
if (
typeof sessionId === 'string' &&
typeof reply === 'string' &&
typeof stored === 'boolean'
) {
finalResponse = {
session_id: sessionId,
reply,
stored,
};
}
}
boundary = buffer.indexOf('\n\n');
}
if (done) {
break;
}
}
if (!finalResponse) {
throw new Error('Assistant stream ended unexpectedly.');
}
return finalResponse;
}
/**
* Check API health.
*/
export async function checkHealth(): Promise<boolean> {
try {
const response = await api.get('/health');
return response.data.status === 'healthy';
} catch {
return false;
}
}
export interface PublicStatsSummary {
total_files_processed: number;
completed_files: number;
failed_files: number;
success_rate: number;
files_last_24h: number;
average_rating: number;
rating_count: number;
top_tools: Array<{ tool: string; count: number }>;
}
/**
* Return public site stats used for social proof and developer onboarding.
*/
export async function getPublicStats(): Promise<PublicStatsSummary> {
const response = await api.get<PublicStatsSummary>('/stats/summary');
return response.data;
}
export interface InternalAdminUser {
id: number;
email: string;
plan: 'free' | 'pro' | string;
role: 'user' | 'admin' | string;
is_allowlisted_admin: boolean;
created_at: string;
total_tasks: number;
completed_tasks: number;
failed_tasks: number;
active_api_keys: number;
}
export interface InternalAdminContact {
id: number;
name: string;
email: string;
category: string;
subject: string | null;
message: string;
created_at: string;
is_read: boolean;
}
export interface InternalAdminOverview {
users: {
total: number;
pro: number;
free: number;
};
processing: {
total_files_processed: number;
completed_files: number;
failed_files: number;
files_last_24h: number;
success_rate: number;
};
ratings: {
average_rating: number;
rating_count: number;
};
ai_cost: {
month: string;
total_usd: number;
budget_usd: number;
percent_used: number;
};
contacts: {
total_messages: number;
unread_messages: number;
recent: InternalAdminContact[];
};
top_tools: Array<{
tool: string;
total_runs: number;
failed_runs: number;
}>;
recent_failures: Array<{
id: number;
user_id: number | null;
email: string | null;
tool: string;
original_filename: string | null;
created_at: string;
metadata: Record<string, unknown>;
}>;
recent_users: Array<{
id: number;
email: string;
plan: string;
created_at: string;
total_tasks: number;
active_api_keys: number;
}>;
}
export async function getInternalAdminOverview(): Promise<InternalAdminOverview> {
const response = await api.get<InternalAdminOverview>('/internal/admin/overview');
return response.data;
}
export async function listInternalAdminUsers(query = '', limit = 25): Promise<InternalAdminUser[]> {
const response = await api.get<{ items: InternalAdminUser[] }>('/internal/admin/users', {
params: {
query,
limit,
},
});
return response.data.items;
}
export async function getInternalAdminContacts(page = 1, perPage = 20): Promise<{
items: InternalAdminContact[];
page: number;
per_page: number;
total: number;
unread: number;
}> {
const response = await api.get<{
items: InternalAdminContact[];
page: number;
per_page: number;
total: number;
unread: number;
}>('/internal/admin/contacts', {
params: {
page,
per_page: perPage,
},
});
return response.data;
}
export async function markInternalAdminContactRead(messageId: number): Promise<void> {
await api.post(`/internal/admin/contacts/${messageId}/read`);
}
export async function updateInternalAdminUserPlan(
userId: number,
plan: 'free' | 'pro'
): Promise<AuthUser> {
const response = await api.post<{ message: string; user: AuthUser }>(
`/internal/admin/users/${userId}/plan`,
{ plan }
);
return response.data.user;
}
export async function updateInternalAdminUserRole(
userId: number,
role: 'user' | 'admin'
): Promise<AuthUser> {
const response = await api.post<{ message: string; user: AuthUser }>(
`/internal/admin/users/${userId}/role`,
{ role }
);
return response.data.user;
}
// --- Enhanced Admin Analytics ---
export interface AdminRatingItem {
id: number;
tool: string;
rating: number;
feedback: string;
tag: string;
created_at: string;
}
export interface AdminToolSummary {
tool: string;
count: number;
average: number;
positive: number;
negative: number;
}
export interface AdminRatingsDetail {
items: AdminRatingItem[];
page: number;
per_page: number;
total: number;
tool_summaries: AdminToolSummary[];
}
export interface AdminToolAnalyticsItem {
tool: string;
total_runs: number;
completed: number;
failed: number;
success_rate: number;
runs_24h: number;
runs_7d: number;
runs_30d: number;
unique_users: number;
}
export interface AdminDailyUsage {
day: string;
total: number;
completed: number;
failed: number;
}
export interface AdminCommonError {
tool: string;
error: string;
occurrences: number;
}
export interface AdminToolAnalytics {
tools: AdminToolAnalyticsItem[];
daily_usage: AdminDailyUsage[];
common_errors: AdminCommonError[];
}
export interface AdminUserStats {
total_users: number;
new_last_7d: number;
new_last_30d: number;
pro_users: number;
free_users: number;
daily_registrations: Array<{ day: string; count: number }>;
most_active_users: Array<{
id: number;
email: string;
plan: string;
created_at: string;
total_tasks: number;
}>;
}
export interface AdminPlanInterest {
total_clicks: number;
unique_users: number;
clicks_last_7d: number;
clicks_last_30d: number;
by_plan: Array<{ plan: string; billing: string; clicks: number }>;
recent: Array<{
id: number;
user_id: number | null;
email: string | null;
plan: string;
billing: string;
created_at: string;
}>;
}
export interface AdminSystemHealth {
ai_configured: boolean;
ai_model: string;
ai_budget_used_percent: number;
error_rate_1h: number;
tasks_last_1h: number;
failures_last_1h: number;
database_size_mb: number;
database_type: string;
}
export interface DatabaseStats {
database_type: string;
tables: Array<{
table_name: string;
row_count: number;
total_size_kb?: number;
data_size_kb?: number;
}>;
table_count: number;
}
export async function getAdminRatingsDetail(page = 1, perPage = 20, tool = ''): Promise<AdminRatingsDetail> {
const response = await api.get<AdminRatingsDetail>('/internal/admin/ratings', {
params: { page, per_page: perPage, ...(tool ? { tool } : {}) },
});
return response.data;
}
export async function getAdminToolAnalytics(): Promise<AdminToolAnalytics> {
const response = await api.get<AdminToolAnalytics>('/internal/admin/tool-analytics');
return response.data;
}
export async function getAdminUserStats(): Promise<AdminUserStats> {
const response = await api.get<AdminUserStats>('/internal/admin/user-stats');
return response.data;
}
export async function getAdminPlanInterest(): Promise<AdminPlanInterest> {
const response = await api.get<AdminPlanInterest>('/internal/admin/plan-interest');
return response.data;
}
export async function getAdminSystemHealth(): Promise<AdminSystemHealth> {
const response = await api.get<AdminSystemHealth>('/internal/admin/system-health');
return response.data;
}
export async function getDatabaseStats(): Promise<DatabaseStats> {
const response = await api.get<DatabaseStats>('/internal/admin/database-stats');
return response.data;
}
export interface ProjectEvent {
time: string;
type: string;
detail: string;
entity_id: number;
}
export interface ProjectEventsResponse {
events: ProjectEvent[];
summary: Record<string, number>;
total_events: number;
period_days: number;
}
export async function getProjectEvents(days = 30): Promise<ProjectEventsResponse> {
const response = await api.get<ProjectEventsResponse>('/internal/admin/project-events', {
params: { days },
});
return response.data;
}
export async function createAdminUser(email: string, password: string, plan = 'free', role = 'user'): Promise<{ message: string; user: InternalAdminUser }> {
const response = await api.post('/internal/admin/users/create', { email, password, plan, role });
return response.data;
}
export async function deleteAdminUser(userId: number): Promise<{ message: string }> {
const response = await api.delete(`/internal/admin/users/${userId}`);
return response.data;
}
export async function updateAdminUserPlan(userId: number, plan: string): Promise<{ message: string; user: InternalAdminUser }> {
const response = await api.put(`/internal/admin/users/${userId}/plan`, { plan });
return response.data;
}
export async function updateAdminUserRole(userId: number, role: string): Promise<{ message: string; user: InternalAdminUser }> {
const response = await api.put(`/internal/admin/users/${userId}/role`, { role });
return response.data;
}
// --- Account / Usage / API Keys ---
export interface UsageSummary {
plan: string;
period_month: string;
ads_enabled: boolean;
history_limit: number;
file_limits_mb: {
pdf: number;
word: number;
image: number;
video: number;
homepageSmartUpload: number;
};
web_quota: { used: number; limit: number | null };
api_quota: { used: number; limit: number | null };
}
export interface ApiKey {
id: number;
name: string;
key_prefix: string;
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
raw_key?: string; // only present on creation
}
/**
* Return the current user's plan, quota, and file-limit summary.
*/
export async function getUsage(): Promise<UsageSummary> {
const response = await api.get<UsageSummary>('/account/usage');
return response.data;
}
/**
* Return all API keys for the authenticated pro user.
*/
export async function getApiKeys(): Promise<ApiKey[]> {
const response = await api.get<{ items: ApiKey[] }>('/account/api-keys');
return response.data.items;
}
/**
* Create a new API key with the given name. Returns the key including raw_key once.
*/
export async function createApiKey(name: string): Promise<ApiKey> {
const response = await api.post<ApiKey>('/account/api-keys', { name });
return response.data;
}
/**
* Revoke one API key by id.
*/
export async function revokeApiKey(keyId: number): Promise<void> {
await api.delete(`/account/api-keys/${keyId}`);
}
export default api;