feat: update environment configuration and enhance API integration

- Remove versioning from docker-compose.yml
- Enhance Vite configuration to support API proxying for development
- Improve error handling and data normalization in API service
- Add fallback values in PublicationsTable and ResearcherCard components
- Update sync functionality in DashboardPage to handle backend responses more effectively
- Refactor mockSyncResearcher to simulate backend response structure
This commit is contained in:
Alexis
2026-04-24 10:40:28 +02:00
parent 89c45b7d67
commit 2bb1309133
9 changed files with 182 additions and 72 deletions
-2
View File
@@ -1,5 +1,3 @@
version: "3.9"
services: services:
backend: backend:
+16 -6
View File
@@ -1,7 +1,17 @@
# Base URL of the FastAPI backend (no trailing slash). # URL base del backend FastAPI (sin barra final).
# Example for local dev: http://localhost:8000 #
VITE_API_URL=http://localhost:8000 # Déjalo VACÍO en desarrollo para que las peticiones pasen por el proxy
# de Vite (ver vite.config.js). El proxy reenvía /researchers y /health
# al destino indicado en VITE_API_PROXY_TARGET.
#
# En producción apunta directamente al backend, p. ej.
# VITE_API_URL=https://api.midominio.com
VITE_API_URL=
# Set to "true" while the backend is not yet implemented. # Solo para dev: destino al que el proxy de Vite reenvía las peticiones.
# All API calls will be served by src/services/mocks.js instead of `fetch`. # Cambia a http://backend:8000 si ejecutas el frontend dentro de docker-compose.
VITE_USE_MOCKS=true VITE_API_PROXY_TARGET=http://localhost:8000
# Pon "true" SOLO si el backend no está disponible y quieres trabajar
# con los fixtures de src/services/mocks.js. En producción debe estar a "false".
VITE_USE_MOCKS=false
@@ -59,9 +59,10 @@ export function PublicationsTable({
const rows = needle const rows = needle
? publications.filter( ? publications.filter(
(p) => (p) =>
p.title.toLowerCase().includes(needle) || (p.title ?? "").toLowerCase().includes(needle) ||
p.journal.toLowerCase().includes(needle) || (p.journal ?? "").toLowerCase().includes(needle) ||
String(p.publication_year).includes(needle), String(p.publication_year ?? "").includes(needle) ||
(p.doi ?? "").toLowerCase().includes(needle),
) )
: publications; : publications;
return sortPublications(rows, sortKey, sortDir); return sortPublications(rows, sortKey, sortDir);
@@ -153,12 +154,13 @@ export function PublicationsTable({
{pub.title} {pub.title}
</td> </td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary"> <td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
{pub.journal} {pub.journal || "—"}
</td> </td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary"> <td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
{pub.publication_year} {pub.publication_year ?? "—"}
</td> </td>
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
{pub.doi ? (
<a <a
href={`https://doi.org/${pub.doi}`} href={`https://doi.org/${pub.doi}`}
target="_blank" target="_blank"
@@ -167,6 +169,11 @@ export function PublicationsTable({
> >
{pub.doi} {pub.doi}
</a> </a>
) : (
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
</span>
)}
</td> </td>
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<Badge type={pub.type} /> <Badge type={pub.type} />
@@ -16,7 +16,7 @@ export function ResearcherCard({ researcher, actions = null }) {
<div className="min-w-[200px] flex-1"> <div className="min-w-[200px] flex-1">
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary"> <h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
{researcher.name} {researcher.name || "Investigador sin nombre"}
</h2> </h2>
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
@@ -25,10 +25,14 @@ export function ResearcherCard({ researcher, actions = null }) {
{researcher.orcid_id} {researcher.orcid_id}
</span> </span>
</div> </div>
{researcher.affiliation && (
<>
<span className="text-surface-border-strong">·</span> <span className="text-surface-border-strong">·</span>
<span className="text-[13px] text-ink-secondary"> <span className="text-[13px] text-ink-secondary">
{researcher.affiliation} {researcher.affiliation}
</span> </span>
</>
)}
</div> </div>
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary"> <div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
<ClockIcon /> <ClockIcon />
+17 -4
View File
@@ -83,12 +83,25 @@ export function DashboardPage() {
async function handleSync() { async function handleSync() {
setSyncStatus("loading"); setSyncStatus("loading");
try { try {
const updated = await syncResearcher(orcid); const summary = await syncResearcher(orcid);
if (updated) setResearcher(updated);
await loadPublications(); if (summary?.status === "error") {
throw new Error(summary.message || "El backend rechazó la sincronización.");
}
// El backend devuelve un resumen del SyncJob, no el researcher.
// Refrescamos ambos recursos en paralelo.
await Promise.all([loadResearcher(), loadPublications()]);
setSyncStatus("success"); setSyncStatus("success");
const total = summary?.total ?? 0;
const nuevos = summary?.new_records ?? 0;
const actualizados = summary?.updated_records ?? 0;
toast.success("Sincronización completada", { toast.success("Sincronización completada", {
description: "Las publicaciones se han actualizado desde ORCID.", description:
total > 0
? `${nuevos} nuevas · ${actualizados} actualizadas (${total} total).`
: summary?.message ?? "Sin cambios desde la última sincronización.",
}); });
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS); setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
} catch (err) { } catch (err) {
+84 -33
View File
@@ -1,13 +1,22 @@
/** /**
* Thin API client for the FastAPI backend. * Cliente HTTP del frontend contra la API FastAPI.
* *
* Every call returns parsed JSON (or a Blob for file downloads) and throws * Cada función devuelve el JSON ya parseado (o un Blob para descargas)
* an `ApiError` on non-2xx responses so callers can decide how to surface it * y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla
* (toast, inline error, retry, etc.). * decide cómo mostrarlo (toast, error inline, reintento, …).
* *
* The base URL is injected at build time via `VITE_API_URL` * La URL base se inyecta en build via `VITE_API_URL` (ver `.env.example`).
* (see `.env.example`). During development, leaving it blank falls back to * En desarrollo la dejamos en blanco para que las peticiones pasen por
* same-origin requests, which plays well with a Vite proxy. * el proxy de Vite (ver `vite.config.js`) y así eludir CORS mientras el
* backend no lo tenga configurado.
*
* Contrato real del backend (prefijo de router: `/researchers`):
* - POST /researchers/?orcid_id=XXXX-XXXX-XXXX-XXXX (crea/upsert)
* - GET /researchers/{orcid_id}
* - POST /researchers/{orcid_id}/sync
* - GET /researchers/{orcid_id}/publications
* - GET /researchers/{orcid_id}/export/sword.xml
* - GET /researchers/{orcid_id}/export/sword.zip
*/ */
import { import {
@@ -20,9 +29,9 @@ import {
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
/** /**
* When the backend is not available yet, set `VITE_USE_MOCKS=true` in your * Si el backend no está disponible, poner `VITE_USE_MOCKS=true` en
* `.env.local` to route every call through `mocks.js`. In production this * `.env.local` para servir todas las llamadas desde `mocks.js`.
* flag MUST be unset. * En producción debe estar desactivado.
*/ */
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true"; const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
@@ -42,7 +51,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
signal, signal,
headers: { headers: {
Accept: "application/json", Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}), ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...headers, ...headers,
}, },
}; };
@@ -52,6 +61,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
try { try {
response = await fetch(url, init); response = await fetch(url, init);
} catch (cause) { } catch (cause) {
if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", { throw new ApiError("No se pudo contactar con el servidor.", {
status: 0, status: 0,
payload: { cause: String(cause) }, payload: { cause: String(cause) },
@@ -63,7 +73,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
try { try {
payload = await response.json(); payload = await response.json();
} catch { } catch {
/* response had no JSON body */ /* sin cuerpo JSON */
} }
const detail = const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error"; payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
@@ -79,47 +89,86 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
return response; return response;
} }
/* ───────────────────────────── Mapeos ────────────────────────────── */
/**
* Adapta el esquema del backend (`pub_year`, campos opcionalmente `null`)
* al que espera la UI (`publication_year`, strings seguras para filtrar).
*/
function normalizePublication(p) {
return {
id: p.id,
put_code: p.put_code ?? null,
title: p.title || "Sin título",
journal: p.journal || "",
doi: p.doi || "",
publication_year: p.pub_year ?? null,
type: p.type || null,
hash_fingerprint: p.hash_fingerprint ?? null,
last_modified: p.last_modified ?? null,
};
}
/* ───────────────────────────── Endpoints ─────────────────────────────── */ /* ───────────────────────────── Endpoints ─────────────────────────────── */
/** POST /api/orcid/validate — validates an ORCID iD and returns the researcher. */ /**
export function validateOrcid(orcidId, { signal } = {}) { * Asegura que el investigador existe en el backend y devuelve su ficha
* completa.
*
* Como el backend no expone un endpoint de validación puro, hacemos:
* 1. POST /researchers/?orcid_id=... (idempotente: crea o devuelve el
* existente; valida formato + dígito de control en el servidor).
* 2. GET /researchers/{orcid_id} (para recuperar el objeto completo:
* name, last_sync_at, etc.).
*/
export async function validateOrcid(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockValidateOrcid(orcidId); if (USE_MOCKS) return mockValidateOrcid(orcidId);
return request("/api/orcid/validate", {
method: "POST", await request(
body: { orcid_id: orcidId }, `/researchers/?orcid_id=${encodeURIComponent(orcidId)}`,
signal, { method: "POST", signal },
}); );
return request(`/researchers/${encodeURIComponent(orcidId)}`, { signal });
} }
/** GET /api/researchers/{orcid}/publications — lists ORCID works. */ /** GET /researchers/{orcid}/publications — normalizado para la UI. */
export function getPublications(orcidId, { signal } = {}) { export async function getPublications(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockGetPublications(orcidId); if (USE_MOCKS) return mockGetPublications(orcidId);
return request(
`/api/researchers/${encodeURIComponent(orcidId)}/publications`, const raw = await request(
`/researchers/${encodeURIComponent(orcidId)}/publications`,
{ signal }, { signal },
); );
return Array.isArray(raw) ? raw.map(normalizePublication) : [];
} }
/** POST /api/researchers/{orcid}/sync — triggers ORCID re-harvest. */ /**
* POST /researchers/{orcid}/sync — dispara el re-harvest desde ORCID.
*
* El backend devuelve un resumen del job (`{status, message, new_records,
* updated_records, total, researcher}`), no el researcher completo.
* El caller debe refetch-ear el researcher y sus publicaciones.
*/
export function syncResearcher(orcidId, { signal } = {}) { export function syncResearcher(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockSyncResearcher(orcidId); if (USE_MOCKS) return mockSyncResearcher(orcidId);
return request(`/api/researchers/${encodeURIComponent(orcidId)}/sync`, {
return request(`/researchers/${encodeURIComponent(orcidId)}/sync`, {
method: "POST", method: "POST",
signal, signal,
}); });
} }
/** /**
* Builds the public export URL so links/anchors can download files directly * Construye la URL pública de exportación para enlaces directos
* without going through `fetch`. Used by the export dropdown. * (sin pasar por `fetch`). La usa el dropdown de exportación.
*/ */
export function getExportUrl(orcidId, format) { export function getExportUrl(orcidId, format) {
return `${BASE_URL}/api/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`; return `${BASE_URL}/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`;
} }
/** /**
* Downloads an export as a Blob (useful when we want to trigger a * Descarga una exportación como Blob (para forzar descarga programática).
* programmatic file download). Falls back to `ApiError` on failure. * Lanza `ApiError` en fallo.
*/ */
export async function downloadExport(orcidId, format, { signal } = {}) { export async function downloadExport(orcidId, format, { signal } = {}) {
if (USE_MOCKS) { if (USE_MOCKS) {
@@ -131,15 +180,17 @@ export async function downloadExport(orcidId, format, { signal } = {}) {
try { try {
response = await fetch(url, { signal }); response = await fetch(url, { signal });
} catch (cause) { } catch (cause) {
if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", { throw new ApiError("No se pudo contactar con el servidor.", {
status: 0, status: 0,
payload: { cause: String(cause) }, payload: { cause: String(cause) },
}); });
} }
if (!response.ok) { if (!response.ok) {
throw new ApiError(`No se pudo exportar el fichero ${format.toUpperCase()}.`, { throw new ApiError(
status: response.status, `No se pudo exportar el fichero ${format.toUpperCase()}.`,
}); { status: response.status },
);
} }
const blob = await response.blob(); const blob = await response.blob();
return { blob, url }; return { blob, url };
+7 -3
View File
@@ -69,10 +69,14 @@ export async function mockGetPublications(/* orcidId */) {
export async function mockSyncResearcher(orcidId) { export async function mockSyncResearcher(orcidId) {
await delay(1800); await delay(1800);
// Imita el payload real del backend (resumen del SyncJob, no el researcher).
return { return {
...MOCK_RESEARCHER, status: "ok",
orcid_id: orcidId, message: "Sincronización completada correctamente.",
last_sync_at: new Date().toISOString(), researcher: orcidId,
new_records: 0,
updated_records: MOCK_PUBLICATIONS.length,
total: MOCK_PUBLICATIONS.length,
}; };
} }
+7 -3
View File
@@ -16,10 +16,14 @@ export function formatDate(iso) {
/** /**
* Builds researcher initials (max 2 chars) from a full name. * Builds researcher initials (max 2 chars) from a full name.
* Si el backend aún no conoce el nombre, devolvemos un guion como
* placeholder para no dejar el avatar vacío.
*/ */
export function getInitials(name = "") { export function getInitials(name) {
return name if (!name || typeof name !== "string") return "";
.trim() const trimmed = name.trim();
if (!trimmed) return "";
return trimmed
.split(/\s+/) .split(/\s+/)
.map((w) => w[0] ?? "") .map((w) => w[0] ?? "")
.slice(0, 2) .slice(0, 2)
+21 -2
View File
@@ -1,8 +1,27 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
return {
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
host: true,
port: 5173,
proxy: {
'/researchers': {
target: proxyTarget,
changeOrigin: true,
},
'/health': {
target: proxyTarget,
changeOrigin: true,
},
},
},
}
}) })