diff --git a/docker-compose.yml b/docker-compose.yml
index 06b2a3d..69cda12 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: "3.9"
-
services:
backend:
diff --git a/frontend/.env.example b/frontend/.env.example
index 8b9eb8c..9195d1b 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,7 +1,17 @@
-# Base URL of the FastAPI backend (no trailing slash).
-# Example for local dev: http://localhost:8000
-VITE_API_URL=http://localhost:8000
+# URL base del backend FastAPI (sin barra final).
+#
+# 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.
-# All API calls will be served by src/services/mocks.js instead of `fetch`.
-VITE_USE_MOCKS=true
+# Solo para dev: destino al que el proxy de Vite reenvía las peticiones.
+# Cambia a http://backend:8000 si ejecutas el frontend dentro de docker-compose.
+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
diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx
index dce58e4..2a56fb2 100644
--- a/frontend/src/components/dashboard/PublicationsTable.jsx
+++ b/frontend/src/components/dashboard/PublicationsTable.jsx
@@ -59,9 +59,10 @@ export function PublicationsTable({
const rows = needle
? publications.filter(
(p) =>
- p.title.toLowerCase().includes(needle) ||
- p.journal.toLowerCase().includes(needle) ||
- String(p.publication_year).includes(needle),
+ (p.title ?? "").toLowerCase().includes(needle) ||
+ (p.journal ?? "").toLowerCase().includes(needle) ||
+ String(p.publication_year ?? "").includes(needle) ||
+ (p.doi ?? "").toLowerCase().includes(needle),
)
: publications;
return sortPublications(rows, sortKey, sortDir);
@@ -153,20 +154,26 @@ export function PublicationsTable({
{pub.title}
- {pub.journal}
+ {pub.journal || "—"}
|
- {pub.publication_year}
+ {pub.publication_year ?? "—"}
|
-
- {pub.doi}
-
+ {pub.doi ? (
+
+ {pub.doi}
+
+ ) : (
+
+ —
+
+ )}
|
diff --git a/frontend/src/components/dashboard/ResearcherCard.jsx b/frontend/src/components/dashboard/ResearcherCard.jsx
index 88ef7ee..546971a 100644
--- a/frontend/src/components/dashboard/ResearcherCard.jsx
+++ b/frontend/src/components/dashboard/ResearcherCard.jsx
@@ -16,7 +16,7 @@ export function ResearcherCard({ researcher, actions = null }) {
- {researcher.name}
+ {researcher.name || "Investigador sin nombre"}
@@ -25,10 +25,14 @@ export function ResearcherCard({ researcher, actions = null }) {
{researcher.orcid_id}
- ·
-
- {researcher.affiliation}
-
+ {researcher.affiliation && (
+ <>
+ ·
+
+ {researcher.affiliation}
+
+ >
+ )}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index fa8d3a5..43f4bb9 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -83,12 +83,25 @@ export function DashboardPage() {
async function handleSync() {
setSyncStatus("loading");
try {
- const updated = await syncResearcher(orcid);
- if (updated) setResearcher(updated);
- await loadPublications();
+ const summary = await syncResearcher(orcid);
+
+ 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");
+ const total = summary?.total ?? 0;
+ const nuevos = summary?.new_records ?? 0;
+ const actualizados = summary?.updated_records ?? 0;
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);
} catch (err) {
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 7fcd1f0..52a693a 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -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
- * an `ApiError` on non-2xx responses so callers can decide how to surface it
- * (toast, inline error, retry, etc.).
+ * Cada función devuelve el JSON ya parseado (o un Blob para descargas)
+ * y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla
+ * decide cómo mostrarlo (toast, error inline, reintento, …).
*
- * The base URL is injected at build time via `VITE_API_URL`
- * (see `.env.example`). During development, leaving it blank falls back to
- * same-origin requests, which plays well with a Vite proxy.
+ * La URL base se inyecta en build via `VITE_API_URL` (ver `.env.example`).
+ * En desarrollo la dejamos en blanco para que las peticiones pasen por
+ * 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 {
@@ -20,9 +29,9 @@ import {
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
/**
- * When the backend is not available yet, set `VITE_USE_MOCKS=true` in your
- * `.env.local` to route every call through `mocks.js`. In production this
- * flag MUST be unset.
+ * Si el backend no está disponible, poner `VITE_USE_MOCKS=true` en
+ * `.env.local` para servir todas las llamadas desde `mocks.js`.
+ * En producción debe estar desactivado.
*/
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
@@ -42,7 +51,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
signal,
headers: {
Accept: "application/json",
- ...(body ? { "Content-Type": "application/json" } : {}),
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...headers,
},
};
@@ -52,6 +61,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
try {
response = await fetch(url, init);
} catch (cause) {
+ if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", {
status: 0,
payload: { cause: String(cause) },
@@ -63,7 +73,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
try {
payload = await response.json();
} catch {
- /* response had no JSON body */
+ /* sin cuerpo JSON */
}
const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
@@ -79,47 +89,86 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
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 ─────────────────────────────── */
-/** 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);
- return request("/api/orcid/validate", {
- method: "POST",
- body: { orcid_id: orcidId },
- signal,
- });
+
+ await request(
+ `/researchers/?orcid_id=${encodeURIComponent(orcidId)}`,
+ { method: "POST", signal },
+ );
+ return request(`/researchers/${encodeURIComponent(orcidId)}`, { signal });
}
-/** GET /api/researchers/{orcid}/publications — lists ORCID works. */
-export function getPublications(orcidId, { signal } = {}) {
+/** GET /researchers/{orcid}/publications — normalizado para la UI. */
+export async function getPublications(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockGetPublications(orcidId);
- return request(
- `/api/researchers/${encodeURIComponent(orcidId)}/publications`,
+
+ const raw = await request(
+ `/researchers/${encodeURIComponent(orcidId)}/publications`,
{ 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 } = {}) {
if (USE_MOCKS) return mockSyncResearcher(orcidId);
- return request(`/api/researchers/${encodeURIComponent(orcidId)}/sync`, {
+
+ return request(`/researchers/${encodeURIComponent(orcidId)}/sync`, {
method: "POST",
signal,
});
}
/**
- * Builds the public export URL so links/anchors can download files directly
- * without going through `fetch`. Used by the export dropdown.
+ * Construye la URL pública de exportación para enlaces directos
+ * (sin pasar por `fetch`). La usa el dropdown de exportación.
*/
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
- * programmatic file download). Falls back to `ApiError` on failure.
+ * Descarga una exportación como Blob (para forzar descarga programática).
+ * Lanza `ApiError` en fallo.
*/
export async function downloadExport(orcidId, format, { signal } = {}) {
if (USE_MOCKS) {
@@ -131,15 +180,17 @@ export async function downloadExport(orcidId, format, { signal } = {}) {
try {
response = await fetch(url, { signal });
} catch (cause) {
+ if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", {
status: 0,
payload: { cause: String(cause) },
});
}
if (!response.ok) {
- throw new ApiError(`No se pudo exportar el fichero ${format.toUpperCase()}.`, {
- status: response.status,
- });
+ throw new ApiError(
+ `No se pudo exportar el fichero ${format.toUpperCase()}.`,
+ { status: response.status },
+ );
}
const blob = await response.blob();
return { blob, url };
diff --git a/frontend/src/services/mocks.js b/frontend/src/services/mocks.js
index 85bc092..a730ac8 100644
--- a/frontend/src/services/mocks.js
+++ b/frontend/src/services/mocks.js
@@ -69,10 +69,14 @@ export async function mockGetPublications(/* orcidId */) {
export async function mockSyncResearcher(orcidId) {
await delay(1800);
+ // Imita el payload real del backend (resumen del SyncJob, no el researcher).
return {
- ...MOCK_RESEARCHER,
- orcid_id: orcidId,
- last_sync_at: new Date().toISOString(),
+ status: "ok",
+ message: "Sincronización completada correctamente.",
+ researcher: orcidId,
+ new_records: 0,
+ updated_records: MOCK_PUBLICATIONS.length,
+ total: MOCK_PUBLICATIONS.length,
};
}
diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js
index 1092d88..34e01da 100644
--- a/frontend/src/utils/formatters.js
+++ b/frontend/src/utils/formatters.js
@@ -16,10 +16,14 @@ export function formatDate(iso) {
/**
* 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 = "") {
- return name
- .trim()
+export function getInitials(name) {
+ if (!name || typeof name !== "string") return "–";
+ const trimmed = name.trim();
+ if (!trimmed) return "–";
+ return trimmed
.split(/\s+/)
.map((w) => w[0] ?? "")
.slice(0, 2)
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index c4069b7..66f0f91 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,8 +1,27 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
-export default defineConfig({
- plugins: [react(), tailwindcss()],
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
+
+ return {
+ plugins: [react(), tailwindcss()],
+ server: {
+ host: true,
+ port: 5173,
+ proxy: {
+ '/researchers': {
+ target: proxyTarget,
+ changeOrigin: true,
+ },
+ '/health': {
+ target: proxyTarget,
+ changeOrigin: true,
+ },
+ },
+ },
+ }
})
|