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, + }, + }, + }, + } })