fix(principal): integra auth, agenda e laudos com a api

This commit is contained in:
EdilbertoC
2026-04-28 10:22:54 -03:00
parent d576fb9784
commit 7199c107f2
20 changed files with 1121 additions and 331 deletions

View File

@@ -1,8 +1,50 @@
import { appointments as mockAppointments } from '../data/mockData.js'
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
import { appointmentMapper } from '../mappers/appointmentMapper.js'
import { fetchJsonWithFallback, normalizeCollection, normalizeItem } from './repositoryUtils.js'
export const appointmentRepository = {
getAll() {
return mockAppointments
async getAll() {
const data = await fetchJsonWithFallback(
[
{
url: apiEndpoint('/agendamentos'),
options: { headers: getAuthenticatedHeaders() },
},
{
url: `${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(name)`,
options: { headers: getAuthenticatedHeaders() },
},
],
'Erro ao buscar agendamentos.',
)
return normalizeCollection(data, ['agendamentos', 'appointments', 'data']).map(appointmentMapper.toUi)
},
async create(uiData) {
const data = await fetchJsonWithFallback(
[
{
url: apiEndpoint('/agendamentos'),
options: {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(appointmentMapper.toApi(uiData)),
},
},
{
url: `${apiConfig.restUrl}/appointments`,
options: {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
},
},
],
'Falha ao criar o agendamento.',
)
return appointmentMapper.toUi(normalizeItem(data, ['agendamento', 'appointment', 'data']))
},
getTodayTimeline() {

View File

@@ -0,0 +1,124 @@
import {
apiConfig,
apiEndpoint,
clearAuthSession,
getAnonHeaders,
getAuthenticatedHeaders,
getAuthSession,
hasAuthenticatedSession,
saveAuthSession,
} from '../config/api.js'
export const authRepository = {
async login({ email, password }) {
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/token?grant_type=password`, {
method: 'POST',
headers: getAnonHeaders(),
body: JSON.stringify({ email: email?.trim(), password }),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro de autenticacao.'))
}
const session = await response.json()
if (!session?.access_token) {
throw new Error('Falha no login. Token nao recebido.')
}
saveAuthSession(session)
return session
},
async requestPasswordReset(email) {
const payload = { email: email?.trim() }
const apiResponse = await fetch(apiEndpoint('/solicitar-reset-de-senha'), {
method: 'POST',
headers: getAnonHeaders(),
body: JSON.stringify(payload),
}).catch(() => null)
if (apiResponse?.ok) {
return true
}
if (apiResponse && !shouldFallback(apiResponse)) {
throw new Error(await getResponseError(apiResponse, 'Erro ao solicitar reset de senha.'))
}
const supabaseResponse = await fetch(`${apiConfig.supabaseUrl}/auth/v1/recover`, {
method: 'POST',
headers: getAnonHeaders(),
body: JSON.stringify(payload),
})
if (!supabaseResponse.ok) {
throw new Error(await getResponseError(supabaseResponse, 'Erro ao enviar link de recuperacao.'))
}
return true
},
async getUser() {
const apiResponse = await fetch(apiEndpoint('/informacoes-do-usuario-autenticado'), {
method: 'GET',
headers: getAuthenticatedHeaders(),
}).catch(() => null)
if (apiResponse?.ok) {
return apiResponse.json()
}
if (apiResponse && !shouldFallback(apiResponse)) {
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.'))
}
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, {
method: 'GET',
headers: getAuthenticatedHeaders(),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuario.'))
}
return response.json()
},
getSession() {
return getAuthSession()
},
isAuthenticated() {
return hasAuthenticatedSession()
},
async logout() {
try {
const apiResponse = await fetch(apiEndpoint('/logout'), {
method: 'POST',
headers: getAuthenticatedHeaders(),
}).catch(() => null)
if (apiResponse?.ok || (apiResponse && !shouldFallback(apiResponse))) return
await fetch(`${apiConfig.supabaseUrl}/auth/v1/logout`, {
method: 'POST',
headers: getAuthenticatedHeaders(),
})
} catch {
// A sessao local precisa ser removida mesmo quando o backend nao responde.
} finally {
clearAuthSession()
}
},
}
function shouldFallback(response) {
return [404, 405].includes(response.status)
}
async function getResponseError(response, fallbackMessage) {
const error = await response.json().catch(() => ({}))
return error.error_description || error.msg || error.message || error.error || fallbackMessage
}

View File

@@ -1,4 +1,42 @@
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
import { fetchJsonWithFallback } from './repositoryUtils.js'
export const communicationRepository = {
async sendSms({ patientName, phone, content }) {
const message = `[MediConnect] Ola ${patientName}, ${content}`
const payload = {
telefone: normalizePhone(phone),
phone: normalizePhone(phone),
mensagem: message,
message,
paciente: patientName,
}
await fetchJsonWithFallback(
[
{
url: apiEndpoint('/enviar-sms-via-twilio'),
options: {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(payload),
},
},
{
url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`,
options: {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(payload),
},
},
],
'Falha no envio de SMS via Twilio.',
)
return true
},
getCampaigns() {
return [
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' },
@@ -31,3 +69,9 @@ export const communicationRepository = {
]
},
}
function normalizePhone(phone) {
const digits = String(phone || '').replace(/\D/g, '')
if (!digits) return ''
return digits.startsWith('55') ? `+${digits}` : `+55${digits}`
}

View File

@@ -1,33 +1,22 @@
import { apiConfig, apiHeaders } from '../config/api.js'
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
export const patientRepository = {
// 1. Listar pacientes
async getAll() {
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: apiHeaders })
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() })
if (!response.ok) throw new Error('Erro ao buscar pacientes')
return response.json()
},
async getById(patientId) {
const patients = await this.getAll()
return patients.find((p) => String(p.id) === String(patientId)) || null
const patient = patients.find((p) => String(p.id) === String(patientId)) || null
return patient ? mapPatientToDetail(patient) : null
},
async getDirectoryRows() {
const patients = await this.getAll()
return patients.map((patient) => ({
...patient,
name: patient.full_name,
phone: patient.phone_mobile,
detailId: patient.id,
insurance: 'Particular',
city: 'Recife',
state: 'PE',
vip: false,
lastVisitIso: null,
lastVisit: 'Ainda nao houve atendimento',
nextVisit: 'Nenhum atendimento agendado',
}))
return patients.map(mapPatientToDirectory)
},
// 2. Criar paciente (direto)
@@ -43,7 +32,7 @@ export const patientRepository = {
const response = await fetch(`${apiConfig.restUrl}/patients`, {
method: 'POST',
headers: { ...apiHeaders, Prefer: 'return=representation' },
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(body),
})
@@ -69,7 +58,7 @@ export const patientRepository = {
const response = await fetch(`${apiConfig.functionsUrl}/create-patient`, {
method: 'POST',
headers: apiHeaders,
headers: getAuthenticatedHeaders(),
body: JSON.stringify(body),
})
@@ -93,7 +82,7 @@ export const patientRepository = {
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
method: 'PATCH',
headers: { ...apiHeaders, Prefer: 'return=representation' },
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(body),
})
@@ -105,10 +94,62 @@ export const patientRepository = {
async remove(patientId) {
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
method: 'DELETE',
headers: apiHeaders,
headers: getAuthenticatedHeaders(),
})
if (!response.ok) throw new Error('Erro ao deletar paciente')
return true
},
}
function mapPatientToDirectory(patient) {
return {
...patient,
name: patient.name || patient.full_name || patient.nome || 'Paciente',
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
detailId: patient.id,
insurance: patient.insurance || patient.convenio || 'Particular',
city: patient.city || patient.cidade || 'Recife',
state: patient.state || patient.uf || 'PE',
vip: Boolean(patient.vip),
lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || null,
lastVisit: patient.lastVisit || patient.last_visit || 'Ainda nao houve atendimento',
nextVisit: patient.nextVisit || patient.next_visit || 'Nenhum atendimento agendado',
}
}
function mapPatientToDetail(patient) {
const directory = mapPatientToDirectory(patient)
return {
...directory,
age: patient.age || patient.idade || calculateAge(patient.birth_date),
document: patient.document || patient.cpf || 'CPF nao informado',
plan: directory.insurance,
condition: patient.condition || patient.condicao || 'Sem condicao principal',
status: patient.status || 'Acompanhamento',
risk: patient.risk || patient.risco || 'Baixo',
email: patient.email || '',
address: patient.address || patient.endereco || 'Endereco nao informado',
team: patient.team || patient.equipe || [],
notes: patient.notes || patient.observacoes || [],
exams: patient.exams || patient.exames || [],
}
}
function calculateAge(birthDate) {
if (!birthDate) return 0
const birth = new Date(birthDate)
if (Number.isNaN(birth.getTime())) return 0
const today = new Date()
let age = today.getFullYear() - birth.getFullYear()
const monthDiff = today.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age -= 1
}
return age
}

View File

@@ -1,8 +1,23 @@
import { professionals as mockProfessionals } from '../data/mockData.js'
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
import { fetchJsonWithFallback, normalizeCollection } from './repositoryUtils.js'
export const professionalRepository = {
getAll() {
return mockProfessionals
async getAll() {
const data = await fetchJsonWithFallback(
[
{
url: apiEndpoint('/listar-medicos'),
options: { headers: getAuthenticatedHeaders() },
},
{
url: `${apiConfig.restUrl}/doctors`,
options: { headers: getAuthenticatedHeaders() },
},
],
'Erro ao buscar medicos.',
)
return normalizeCollection(data, ['medicos', 'doctors', 'professionals', 'data']).map(mapProfessional)
},
getCoverageMap() {
@@ -12,3 +27,15 @@ export const professionalRepository = {
}
},
}
function mapProfessional(doctor) {
return {
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome),
name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)',
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Medico(a)',
schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h',
nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente',
patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0,
status: doctor.status || doctor.situacao || 'Disponivel',
}
}

View File

@@ -1,11 +1,74 @@
import { authRepository } from './authRepository.js'
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
import { getResponseError } from './repositoryUtils.js'
export const profileRepository = {
getCurrentUserProfile() {
async getCurrentUserProfile() {
const data = await authRepository.getUser()
const user = data?.user || data?.usuario || data?.profile || data
const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {}
const avatarUrl = user?.avatarUrl || user?.avatar_url || meta.avatar_url || meta.picture || ''
return {
email: 'henrique.cardoso@mediconnect.com.br',
name: 'Dr. Henrique Cardoso',
phone: '(81) 98888-0101',
role: 'Medico Clinico Geral',
unit: 'Clinica Boa Vista',
id: user?.id || user?.user_id || user?.uid || '',
email: user?.email || meta.email || '',
name: user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
phone: user?.phone || user?.telefone || meta.phone || meta.telefone || '',
role: user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema',
unit: user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista',
avatarUrl,
}
},
async updateAvatar(file) {
const profile = await this.getCurrentUserProfile()
const formData = new FormData()
formData.append('avatar', file)
formData.append('file', file)
const apiResponse = await fetch(apiEndpoint('/upload-avatar'), {
method: 'POST',
headers: getAuthenticatedHeaders({ 'Content-Type': undefined }),
body: formData,
}).catch(() => null)
if (apiResponse?.ok) {
return normalizeAvatarResponse(await apiResponse.json().catch(() => ({})))
}
if (apiResponse && ![404, 405].includes(apiResponse.status)) {
throw new Error(await getResponseError(apiResponse, 'Falha ao enviar avatar.'))
}
if (!profile.id) {
throw new Error('Nao foi possivel identificar o usuario para enviar o avatar.')
}
const extension = file.name?.split('.').pop() || 'jpg'
const objectPath = `${profile.id}/avatar.${extension}`
const response = await fetch(`${apiConfig.storageUrl}/object/avatars/${objectPath}`, {
method: 'POST',
headers: getAuthenticatedHeaders({
'Content-Type': file.type || 'application/octet-stream',
'x-upsert': 'true',
}),
body: file,
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao enviar avatar.'))
}
return {
avatarUrl: `${apiConfig.storageUrl}/object/public/avatars/${objectPath}`,
path: objectPath,
}
},
}
function normalizeAvatarResponse(data) {
return {
avatarUrl: data.avatarUrl || data.avatar_url || data.publicUrl || data.public_url || data.url || '',
path: data.path || data.key || '',
}
}

View File

@@ -1,121 +1,142 @@
const reportTypes = [
'Atestado Medico',
'Laudo de Exame',
'Laudo de Imagem',
'Relatorio Cirurgico',
'Declaracao de Acompanhante',
'Encaminhamento',
]
const doctors = ['Dra. Ana Silva', 'Dr. Carlos Mendes', 'Dr. Roberto Nunes']
const currentUser = 'Dra. Ana Silva'
const adminUsers = ['Dr. Roberto Nunes']
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
import { reportMapper } from '../mappers/reportMapper.js'
import { fetchJsonWithFallback, normalizeCollection, normalizeItem } from './repositoryUtils.js'
export const reportRepository = {
getAdminUsers() {
return adminUsers
async getInitialReports() {
const data = await fetchJsonWithFallback(
[
{
url: apiEndpoint('/reports'),
options: { headers: getAuthenticatedHeaders() },
},
{
url: `${apiConfig.restUrl}/reports?select=*,patients(full_name),doctors(name)`,
options: { headers: getAuthenticatedHeaders() },
},
],
'Falha ao buscar laudos da API.',
)
return normalizeCollection(data, ['reports', 'relatorios', 'laudos', 'data']).map(reportMapper.toUi)
},
getCurrentUser() {
return currentUser
async create(uiData) {
const data = await fetchJsonWithFallback(
[
{
url: apiEndpoint('/reports'),
options: {
method: 'POST',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(reportMapper.toApi(uiData)),
},
},
{
url: `${apiConfig.restUrl}/reports`,
options: {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(reportMapper.toApi(uiData, 'supabase')),
},
},
],
'Falha ao salvar laudo.',
)
return reportMapper.toUi(normalizeItem(data, ['report', 'relatorio', 'laudo', 'data']))
},
getDoctors() {
return doctors
},
async update(id, uiData) {
const data = await fetchJsonWithFallback(
[
{
url: apiEndpoint(`/reports/${id}`),
options: {
method: 'PATCH',
headers: getAuthenticatedHeaders(),
body: JSON.stringify(reportMapper.toApi({ ...uiData, id })),
},
},
{
url: apiEndpoint('/reports'),
options: {
method: 'PATCH',
headers: getAuthenticatedHeaders(),
body: JSON.stringify({ id, ...reportMapper.toApi(uiData) }),
},
},
{
url: `${apiConfig.restUrl}/reports?id=eq.${id}`,
options: {
method: 'PATCH',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(reportMapper.toApi(uiData, 'supabase')),
},
},
],
'Falha ao atualizar o laudo.',
)
getInitialReports() {
return [
{
id: 'report-1',
type: 'Atestado Medico',
patient: 'Carlos Eduardo Santos',
doctor: 'Dra. Ana Silva',
date: '27/03/2026',
status: 'finalizado',
content: 'Atesto que o paciente esteve em consulta medica nesta data, necessitando de repouso por 2 dias.',
showDate: true,
signDigital: true,
versions: [
{ version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' },
{ version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Ajuste no periodo de repouso' },
{ version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Laudo liberado e finalizado' },
],
},
{
id: 'report-2',
type: 'Laudo de Exame',
patient: 'Mariana Costa',
doctor: 'Dra. Ana Silva',
date: '26/03/2026',
status: 'enviado',
content: 'Laudo referente ao exame de ecocardiograma. Resultado dentro dos parametros normais.',
showDate: true,
signDigital: true,
versions: [
{ version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Laudo criado' },
{ version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Adicao da data do exame' },
{ version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao incluida' },
{ version: 4, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' },
],
},
{
id: 'report-3',
type: 'Relatorio Cirurgico',
patient: 'Fernanda Lima',
doctor: 'Dr. Carlos Mendes',
date: '25/03/2026',
status: 'rascunho',
content: 'Relatorio do procedimento de colecistectomia laparoscopica realizado sob anestesia geral.',
showDate: false,
signDigital: true,
versions: [
{ version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Relatorio criado' },
{ version: 2, action: 'Rascunho', user: 'Dr. Carlos Mendes', summary: 'Detalhamento do procedimento' },
],
},
{
id: 'report-4',
type: 'Declaracao de Acompanhante',
patient: 'Joao Pedro Alves',
doctor: 'Dr. Roberto Nunes',
date: '24/03/2026',
status: 'finalizado',
content: 'Declaro que o acompanhante esteve presente durante todo o periodo de internacao.',
showDate: true,
signDigital: false,
versions: [
{ version: 1, action: 'Criado', user: 'Dr. Roberto Nunes', summary: 'Declaracao criada e liberada' },
],
},
{
id: 'report-5',
type: 'Laudo de Imagem',
patient: 'Roberto Campos',
doctor: 'Dra. Ana Silva',
date: '22/03/2026',
status: 'enviado',
content: 'Ultrassonografia de abdomen total sem achados patologicos relevantes.',
showDate: true,
signDigital: true,
versions: [
{ version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' },
{ version: 2, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao adicionada' },
{ version: 3, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' },
],
},
]
},
getReportTypes() {
return reportTypes
return reportMapper.toUi(normalizeItem(data, ['report', 'relatorio', 'laudo', 'data']))
},
getTemplates() {
return [
{ id: 'template-1', name: 'Atestado de Repouso Simples', type: 'Atestado Medico', description: 'Atestado padrao concedendo dias de repouso ao paciente.', content: 'Atesto, para os devidos fins, que o(a) paciente necessita de repouso pelo periodo indicado.' },
{ id: 'template-2', name: 'Laudo de Hemograma', type: 'Laudo de Exame', description: 'Resultado de hemograma completo com interpretacao clinica.', content: 'Laudo de hemograma completo com parametros avaliados e interpretacao clinica.' },
{ id: 'template-3', name: 'Relatorio Cirurgico', type: 'Relatorio Cirurgico', description: 'Relatorio padronizado para procedimento cirurgico.', content: 'Relatorio do procedimento cirurgico, achados, conduta e evolucao imediata.' },
{
id: 't1',
name: 'Atestado Medico Padrao',
type: 'Atestado Medico',
description: 'Atestado simples para repouso, consulta e CID.',
content:
'Atesto para os devidos fins que o(a) paciente [NOME DO PACIENTE] esteve em consulta medica nesta data, necessitando de [DIAS] dias de repouso por motivo de saude (CID: [CODIGO]).',
},
{
id: 't2',
name: 'Encaminhamento Especializado',
type: 'Encaminhamento',
description: 'Encaminhamento para avaliacao de especialidade.',
content:
'Encaminho o(a) paciente [NOME DO PACIENTE] para avaliacao da especialidade de [ESPECIALIDADE] devido ao quadro clinico de [SINTOMAS/DIAGNOSTICO PREVIO].\n\nConduta mantida ate o momento: [MEDICACOES]',
},
{
id: 't3',
name: 'Laudo de Evolucao Diaria',
type: 'Evolucao Clinica',
description: 'Modelo para evolucao clinica diaria.',
content:
'Paciente evolui [BEM/MAL], [COM/SEM] queixas no momento.\nSinais vitais: PA [VALOR], FC [VALOR] bpm, SatO2 [VALOR]%.\nExame fisico: [DESCRICAO].\nConduta: [MANTER/ALTERAR TRATAMENTO OPCOES].',
},
{
id: 't4',
name: 'Receituario de Uso Continuo',
type: 'Receituario Fixado',
description: 'Lista de medicamentos de uso continuo.',
content:
'Uso continuo:\n1. [MEDICAMENTO] - [DOSE] - Tomar [POSOLOGIA]\n2. [MEDICAMENTO] - [DOSE] - Tomar [POSOLOGIA]',
},
]
},
getAdminUsers() {
return ['Dr. Henrique Cardoso', 'Dra. Marina Lopes', 'Dra. Ana Silva']
},
getCurrentUser() {
return 'Dr. Henrique Cardoso'
},
getDoctors() {
return ['Dr. Henrique Cardoso', 'Dra. Marina Lopes', 'Dra. Ana Silva', 'Dr. Roberto Santos']
},
getReportTypes() {
return [
'Atestado Medico',
'Encaminhamento',
'Evolucao Clinica',
'Receituario Fixado',
'Laudo de Procedimento',
]
},
}

View File

@@ -0,0 +1,74 @@
export async function fetchJsonWithFallback(requests, fallbackMessage) {
let lastResponse = null
let lastError = null
for (const request of requests) {
let response
try {
response = await fetch(request.url, request.options)
lastResponse = response
} catch (error) {
lastError = error
continue
}
if (response.ok) {
return parseJsonResponse(response)
}
if (!shouldFallback(response)) {
throw new Error(await getResponseError(response, fallbackMessage))
}
}
if (lastError && !lastResponse) {
throw new Error(lastError.message || fallbackMessage)
}
throw new Error(await getResponseError(lastResponse, fallbackMessage))
}
export function normalizeCollection(data, keys = []) {
if (Array.isArray(data)) return data
for (const key of keys) {
if (Array.isArray(data?.[key])) return data[key]
}
return []
}
export function normalizeItem(data, keys = []) {
if (Array.isArray(data)) return data[0] || null
for (const key of keys) {
if (data?.[key]) return data[key]
}
return data || null
}
export async function getResponseError(response, fallbackMessage) {
if (!response) return fallbackMessage
const error = await response.json().catch(() => ({}))
return error.error_description || error.msg || error.message || error.error || fallbackMessage
}
function shouldFallback(response) {
return [404, 405].includes(response.status)
}
async function parseJsonResponse(response) {
if (response.status === 204) return null
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return { message: text }
}
}