- Fix: Avatar upload usando Supabase Client com RLS policies - Fix: Profile update usando Supabase Client - Fix: Timezone handling em datas de consultas - Fix: Filtros de consultas passadas/futuras - Fix: Appointment cancellation com Supabase Client - Fix: Navegação após booking de consulta - Fix: Report service usando Supabase Client - Fix: Campo created_by em relatórios - Fix: URL pública de avatares no Storage - Fix: Modal de criação de usuário com scroll - Feat: Sistema completo de gestão de consultas - Feat: Painéis para paciente, médico, secretária e admin - Feat: Upload de avatares - Feat: Sistema de relatórios médicos - Feat: Gestão de disponibilidade de médicos
367 lines
10 KiB
TypeScript
367 lines
10 KiB
TypeScript
/**
|
|
* Cliente HTTP usando Axios
|
|
* Chamadas diretas ao Supabase
|
|
*/
|
|
|
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
|
import { API_CONFIG } from "./config";
|
|
|
|
class ApiClient {
|
|
private client: AxiosInstance;
|
|
|
|
constructor() {
|
|
this.client = axios.create({
|
|
baseURL: API_CONFIG.REST_URL,
|
|
timeout: API_CONFIG.TIMEOUT,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
apikey: API_CONFIG.SUPABASE_ANON_KEY,
|
|
},
|
|
});
|
|
|
|
this.setupInterceptors();
|
|
}
|
|
|
|
private setupInterceptors() {
|
|
// Request interceptor - adiciona token automaticamente
|
|
this.client.interceptors.request.use(
|
|
(config) => {
|
|
// Não adicionar token se a flag _skipAuth estiver presente
|
|
if ((config as any)._skipAuth) {
|
|
delete (config as any)._skipAuth;
|
|
return config;
|
|
}
|
|
|
|
const token = localStorage.getItem(
|
|
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN
|
|
);
|
|
|
|
if (token && config.headers) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
console.log(
|
|
`[ApiClient] Request: ${config.method?.toUpperCase()} ${
|
|
config.url
|
|
} - Token presente: ${token.substring(0, 20)}...`
|
|
);
|
|
} else {
|
|
console.warn(
|
|
`[ApiClient] Request: ${config.method?.toUpperCase()} ${
|
|
config.url
|
|
} - SEM TOKEN!`
|
|
);
|
|
}
|
|
|
|
return config;
|
|
},
|
|
(error) => {
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Response interceptor - trata erros globalmente
|
|
this.client.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const originalRequest = error.config;
|
|
|
|
console.error(`[ApiClient] Erro na requisição:`, {
|
|
url: originalRequest?.url,
|
|
status: error.response?.status,
|
|
message: error.message,
|
|
});
|
|
|
|
// Se retornar 401 e não for uma requisição de refresh/login
|
|
if (
|
|
error.response?.status === 401 &&
|
|
!originalRequest._retry &&
|
|
!originalRequest.url?.includes("auth-refresh") &&
|
|
!originalRequest.url?.includes("auth-login")
|
|
) {
|
|
originalRequest._retry = true;
|
|
|
|
console.log("[ApiClient] Recebeu 401, tentando renovar token...");
|
|
|
|
try {
|
|
// Tenta renovar o token
|
|
const refreshToken = localStorage.getItem(
|
|
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN
|
|
);
|
|
|
|
if (refreshToken) {
|
|
console.log("[ApiClient] Refresh token encontrado, renovando...");
|
|
|
|
// Chama Supabase diretamente para renovar token
|
|
const response = await axios.post(
|
|
`${API_CONFIG.AUTH_URL}/token?grant_type=refresh_token`,
|
|
{
|
|
refresh_token: refreshToken,
|
|
},
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
apikey: API_CONFIG.SUPABASE_ANON_KEY,
|
|
},
|
|
}
|
|
);
|
|
|
|
const {
|
|
access_token,
|
|
refresh_token: newRefreshToken,
|
|
user,
|
|
} = response.data;
|
|
|
|
console.log("[ApiClient] Token renovado com sucesso!");
|
|
|
|
// Atualiza tokens
|
|
localStorage.setItem(
|
|
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
|
|
access_token
|
|
);
|
|
localStorage.setItem(
|
|
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN,
|
|
newRefreshToken
|
|
);
|
|
localStorage.setItem(
|
|
API_CONFIG.STORAGE_KEYS.USER,
|
|
JSON.stringify(user)
|
|
);
|
|
|
|
// Atualiza o header da requisição original
|
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
|
|
|
// Refaz a requisição original
|
|
return this.client(originalRequest);
|
|
} else {
|
|
console.warn("[ApiClient] Nenhum refresh token disponível!");
|
|
}
|
|
} catch (refreshError) {
|
|
console.error(
|
|
"[ApiClient] Falha ao renovar token, fazendo logout...",
|
|
refreshError
|
|
);
|
|
// Se refresh falhar, limpa tudo e redireciona para home
|
|
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
|
|
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN);
|
|
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.USER);
|
|
|
|
// Redireciona para home ao invés de login específico
|
|
if (
|
|
!window.location.pathname.includes("/login") &&
|
|
window.location.pathname !== "/"
|
|
) {
|
|
window.location.href = "/";
|
|
}
|
|
|
|
return Promise.reject(refreshError);
|
|
}
|
|
}
|
|
|
|
// Se não conseguir renovar, apenas loga o erro mas NÃO limpa a sessão
|
|
// Deixa o componente decidir o que fazer
|
|
if (error.response?.status === 401) {
|
|
console.error(
|
|
"[ApiClient] 401 não tratado - requisição:",
|
|
originalRequest?.url
|
|
);
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
}
|
|
|
|
// Métodos HTTP
|
|
async get<T>(
|
|
url: string,
|
|
config?: AxiosRequestConfig
|
|
): Promise<AxiosResponse<T>> {
|
|
console.log(
|
|
"[ApiClient] GET Request:",
|
|
url,
|
|
"Params:",
|
|
JSON.stringify(config?.params)
|
|
);
|
|
|
|
const response = await this.client.get<T>(url, config);
|
|
|
|
console.log("[ApiClient] GET Response:", {
|
|
status: response.status,
|
|
dataType: typeof response.data,
|
|
isArray: Array.isArray(response.data),
|
|
dataLength: Array.isArray(response.data)
|
|
? response.data.length
|
|
: "not array",
|
|
});
|
|
console.log(
|
|
"[ApiClient] Response Data:",
|
|
JSON.stringify(response.data, null, 2)
|
|
);
|
|
|
|
return response;
|
|
}
|
|
|
|
async post<T>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: AxiosRequestConfig
|
|
): Promise<AxiosResponse<T>> {
|
|
console.log("[ApiClient] POST Request:", {
|
|
url,
|
|
fullUrl: `${this.client.defaults.baseURL}${url}`,
|
|
data,
|
|
config,
|
|
});
|
|
|
|
try {
|
|
const response = await this.client.post<T>(url, data, config);
|
|
console.log("[ApiClient] POST Response:", {
|
|
status: response.status,
|
|
data: response.data,
|
|
});
|
|
return response;
|
|
} catch (error: any) {
|
|
console.error("[ApiClient] POST Error:", {
|
|
url,
|
|
fullUrl: `${this.client.defaults.baseURL}${url}`,
|
|
status: error?.response?.status,
|
|
statusText: error?.response?.statusText,
|
|
data: error?.response?.data,
|
|
message: error?.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST público - não envia token de autenticação
|
|
* Usado para endpoints de auto-registro
|
|
*/
|
|
async postPublic<T>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: AxiosRequestConfig
|
|
): Promise<AxiosResponse<T>> {
|
|
const configWithoutAuth = {
|
|
...config,
|
|
headers: {
|
|
...config?.headers,
|
|
// Remove Authorization header
|
|
},
|
|
// Flag especial para o interceptor saber que não deve adicionar token
|
|
_skipAuth: true,
|
|
};
|
|
return this.client.post<T>(url, data, configWithoutAuth);
|
|
}
|
|
|
|
async patch<T>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: AxiosRequestConfig
|
|
): Promise<AxiosResponse<T>> {
|
|
console.log("[ApiClient] PATCH Request:", {
|
|
url,
|
|
data,
|
|
config,
|
|
});
|
|
|
|
try {
|
|
// Adicionar header Prefer para Supabase retornar os dados atualizados
|
|
const configWithPrefer = {
|
|
...config,
|
|
headers: {
|
|
...config?.headers,
|
|
Prefer: "return=representation",
|
|
},
|
|
};
|
|
|
|
const response = await this.client.patch<T>(url, data, configWithPrefer);
|
|
console.log("[ApiClient] PATCH Response:", {
|
|
status: response.status,
|
|
data: response.data,
|
|
});
|
|
return response;
|
|
} catch (error: any) {
|
|
console.error("[ApiClient] PATCH Error:", {
|
|
url,
|
|
status: error?.response?.status,
|
|
statusText: error?.response?.statusText,
|
|
data: error?.response?.data,
|
|
message: error?.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async delete<T>(
|
|
url: string,
|
|
config?: AxiosRequestConfig
|
|
): Promise<AxiosResponse<T>> {
|
|
return this.client.delete<T>(url, config);
|
|
}
|
|
|
|
async put<T>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: AxiosRequestConfig
|
|
): Promise<AxiosResponse<T>> {
|
|
return this.client.put<T>(url, data, config);
|
|
}
|
|
|
|
/**
|
|
* Chama uma Edge Function do Supabase
|
|
* Usa a baseURL de Functions em vez de REST
|
|
*/
|
|
async callFunction<T>(
|
|
functionName: string,
|
|
data?: unknown,
|
|
config?: AxiosRequestConfig
|
|
): Promise<AxiosResponse<T>> {
|
|
const fullUrl = `${API_CONFIG.FUNCTIONS_URL}/${functionName}`;
|
|
|
|
console.log("[ApiClient] Calling Edge Function:", {
|
|
functionName,
|
|
fullUrl,
|
|
data,
|
|
});
|
|
|
|
// Cria uma requisição sem baseURL
|
|
const functionsClient = axios.create({
|
|
timeout: API_CONFIG.TIMEOUT,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
apikey: API_CONFIG.SUPABASE_ANON_KEY,
|
|
},
|
|
});
|
|
|
|
// Adiciona token se disponível
|
|
const token = localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
|
|
if (token) {
|
|
functionsClient.defaults.headers.common[
|
|
"Authorization"
|
|
] = `Bearer ${token}`;
|
|
}
|
|
|
|
try {
|
|
const response = await functionsClient.post<T>(fullUrl, data, config);
|
|
console.log("[ApiClient] Edge Function Response:", {
|
|
functionName,
|
|
status: response.status,
|
|
data: response.data,
|
|
});
|
|
return response;
|
|
} catch (error: any) {
|
|
console.error("[ApiClient] Edge Function Error:", {
|
|
functionName,
|
|
fullUrl,
|
|
status: error?.response?.status,
|
|
statusText: error?.response?.statusText,
|
|
data: error?.response?.data,
|
|
message: error?.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient();
|