diff --git a/docker-compose.yml b/docker-compose.yml index 45e2e0c..a27dd7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,10 @@ services: DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db REDIS_URL: redis://redis:6379/0 depends_on: - - db - - redis + db: + condition: service_healthy + redis: + condition: service_started frontend: build: ./frontend @@ -38,6 +40,11 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d orcid_db"] + interval: 2s + timeout: 3s + retries: 20 redis: image: redis:7 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3e328fd --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,31 @@ +# No copiar artefactos del host al contenedor. +# +# El error "sh: vite: not found" al hacer `docker compose up` aparece +# cuando los node_modules del host (Windows / macOS) sobrescriben los +# que `npm install` acaba de instalar dentro del contenedor Linux. +# Excluyéndolos aquí, el `COPY . .` del Dockerfile no los pisa. +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Builds locales / cachés. +dist/ +build/ +.vite/ +*.timestamp-* + +# Secretos: docker-compose ya inyecta las variables vía `env_file`, +# no necesitamos copiarlos al filesystem de la imagen. +.env +.env.* +!.env.example + +# Editor / OS. +.git/ +.gitignore +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/frontend/.env.example b/frontend/.env.example index 9195d1b..bb24d07 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,17 +1,21 @@ # 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= +# En desarrollo puedes dejarlo en blanco y el proxy de Vite +# (ver vite.config.js) reenviará todo lo que cuelgue de /api al +# destino indicado en VITE_API_PROXY_TARGET. Esto evita problemas +# de CORS sin exponer el host del backend al navegador. +VITE_API_URL=http://localhost:8000/api -# 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. +# Solo para dev: destino al que el proxy de Vite reenvía las peticiones +# que empiecen por /api. Cambia a http://backend:8000 si ejecutas el +# frontend dentro de docker-compose. VITE_API_PROXY_TARGET=http://localhost:8000 +# Clave compartida con el backend. Se inyecta como header `X-API-Key` +# en TODAS las peticiones salientes (ver src/services/api.js). Debe +# coincidir con `API_KEY_VALUE` del .env del backend. +VITE_API_KEY=12ao.9-8a7b-4c&d-9e,f-?89abc + # 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/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 5d99ceb..5f0ca43 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; -import { useParams, Navigate } from "react-router-dom"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation, useParams, Navigate } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; @@ -10,10 +10,8 @@ import { ExportDropdown } from "../components/dashboard/ExportDropdown"; import { SyncButton } from "../components/dashboard/SyncButton"; import { downloadExport, - getExportUrl, - getPublications, + searchResearcher, syncResearcher, - validateOrcid, } from "../services/api"; import { isValidOrcid } from "../utils/orcid"; @@ -21,16 +19,27 @@ const SUCCESS_FLASH_MS = 3000; /** * Researcher detail page. Owns: - * - Initial researcher lookup (validate + publications fetch on mount). - * - Sync workflow (POST + refresh + success toast). - * - Export workflow (download blob + success/error toast). + * - Carga inicial vía `searchResearcher` (todo en uno: researcher + + * publications + resumen de cambios). Si llegamos desde la landing + * usamos el bundle ya cargado en `location.state` para evitar + * duplicar la petición. + * - Re-sync manual (POST + actualización de estado in-place + toast). + * - Exportación SWORD/ZIP (selectiva si hay selección, masiva si no). */ export function DashboardPage() { const { orcid } = useParams(); + const location = useLocation(); + // El bundle del Landing solo lo consumimos UNA vez: la primera vez + // que se monta el componente. Si el usuario refresca, navega o vuelve + // atrás, queremos que se vuelva a pedir al backend. + const initialBundleRef = useRef(location.state?.bundle ?? null); - const [researcher, setResearcher] = useState(null); - const [publications, setPublications] = useState([]); - const [pubsLoading, setPubsLoading] = useState(true); + const initialBundle = initialBundleRef.current; + const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null); + const [publications, setPublications] = useState( + initialBundle?.publications ?? [], + ); + const [pubsLoading, setPubsLoading] = useState(!initialBundle); const [pubsError, setPubsError] = useState(null); const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success @@ -38,40 +47,35 @@ export function DashboardPage() { const [selectedIds, setSelectedIds] = useState(() => new Set()); - const loadResearcher = useCallback( - async (signal) => { - try { - const data = await validateOrcid(orcid, { signal }); - if (!signal?.aborted) setResearcher(data); - } catch (err) { - if (signal?.aborted) return; - toast.error("No se pudo cargar el investigador", { - description: err?.message ?? "Error desconocido.", - }); - } - }, - [orcid], - ); - - const loadPublications = useCallback( + /** + * Carga (o recarga) el bundle completo del investigador. Centralizamos + * la lógica aquí para que tanto el `useEffect` inicial como el botón + * "Reintentar" del estado de error compartan código. + */ + const loadBundle = useCallback( async (signal) => { setPubsLoading(true); setPubsError(null); try { - const data = await getPublications(orcid, { signal }); - if (!signal?.aborted) { - setPublications(data); - setSelectedIds((prev) => { - if (prev.size === 0) return prev; - const alive = new Set(data.map((p) => p.id)); - const next = new Set(); - for (const id of prev) if (alive.has(id)) next.add(id); - return next.size === prev.size ? prev : next; - }); - } + const bundle = await searchResearcher(orcid, { signal }); + if (signal?.aborted) return; + setResearcher(bundle.researcher); + setPublications(bundle.publications); + // La selección sobrevive recargas: nos quedamos con los IDs que + // siguen existiendo tras el sync, descartamos los que no. + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const alive = new Set(bundle.publications.map((p) => p.id)); + const next = new Set(); + for (const id of prev) if (alive.has(id)) next.add(id); + return next.size === prev.size ? prev : next; + }); } catch (err) { if (signal?.aborted) return; setPubsError(err); + toast.error("No se pudo cargar el investigador", { + description: err?.message ?? "Error desconocido.", + }); } finally { if (!signal?.aborted) setPubsLoading(false); } @@ -81,11 +85,17 @@ export function DashboardPage() { useEffect(() => { if (!isValidOrcid(orcid)) return; + // Si venimos del Landing con el bundle precargado, evitamos la + // segunda petición y consumimos el ref para que un refresh sí pegue + // al backend. + if (initialBundleRef.current) { + initialBundleRef.current = null; + return; + } const ctrl = new AbortController(); - loadResearcher(ctrl.signal); - loadPublications(ctrl.signal); + loadBundle(ctrl.signal); return () => ctrl.abort(); - }, [orcid, loadResearcher, loadPublications]); + }, [orcid, loadBundle]); if (!isValidOrcid(orcid)) { return ; @@ -94,23 +104,24 @@ export function DashboardPage() { async function handleSync() { setSyncStatus("loading"); try { - const summary = await syncResearcher(orcid); - - if (summary?.status === "error") { - throw new Error(summary.message || "El backend rechazó la sincronización."); - } - - await Promise.all([loadResearcher(), loadPublications()]); + const bundle = await syncResearcher(orcid); + setResearcher(bundle.researcher); + setPublications(bundle.publications); + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const alive = new Set(bundle.publications.map((p) => p.id)); + const next = new Set(); + for (const id of prev) if (alive.has(id)) next.add(id); + return next.size === prev.size ? prev : next; + }); setSyncStatus("success"); - const total = summary?.total ?? 0; - const nuevos = summary?.new_records ?? 0; - const actualizados = summary?.updated_records ?? 0; + const { newRecords, updatedRecords, totalRecords } = bundle; + const hasChanges = newRecords > 0 || updatedRecords > 0; toast.success("Sincronización completada", { - description: - total > 0 - ? `${nuevos} nuevas · ${actualizados} actualizadas (${total} total).` - : summary?.message ?? "Sin cambios desde la última sincronización.", + description: hasChanges + ? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).` + : "Sin cambios desde la última sincronización.", }); setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS); } catch (err) { @@ -125,21 +136,27 @@ export function DashboardPage() { setExportingFormat(format); try { const ids = Array.from(selectedIds); - const { blob, url } = await downloadExport(orcid, format, { + const { blob } = await downloadExport(orcid, format, { publicationIds: ids.length > 0 ? ids : undefined, }); if (blob) { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; - anchor.download = `sword-${orcid}.${format}`; + // Usamos extensiones reales: el endpoint SWORD devuelve XML. + const extension = format === "xml" ? "xml" : format; + anchor.download = `sword-${orcid}.${extension}`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(objectUrl); } + const scope = + ids.length > 0 + ? `${ids.length} publicación${ids.length === 1 ? "" : "es"} seleccionada${ids.length === 1 ? "" : "s"}` + : "todo el investigador"; toast.success(`Exportación ${format.toUpperCase()} completada`, { - description: url ?? getExportUrl(orcid, format), + description: scope, }); } catch (err) { toast.error(`Error al exportar ${format.toUpperCase()}`, { @@ -179,7 +196,7 @@ export function DashboardPage() { publications={publications} loading={pubsLoading} error={pubsError} - onRetry={() => loadPublications()} + onRetry={() => loadBundle()} selectedIds={selectedIds} onSelectedIdsChange={setSelectedIds} /> diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 62dfdac..f21418b 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -7,11 +7,18 @@ import { DocumentIcon } from "../components/ui/Icons"; import { OrcidLogo } from "../components/ui/OrcidLogo"; import { Spinner } from "../components/ui/Spinner"; import { formatOrcidInput, isValidOrcid } from "../utils/orcid"; -import { validateOrcid } from "../services/api"; +import { searchResearcher } from "../services/api"; /** * Entry view: OAuth button + manual ORCID iD entry. - * Navigates to `/dashboard/:orcid` after a successful `validateOrcid` call. + * + * El endpoint de búsqueda grupal `POST /api/researchers/search` (usado + * para 1 solo ORCID) es "todo en uno": + * valida el formato + dígito de control en el servidor, lo crea en BD si + * no existe, sincroniza con ORCID y devuelve `researcher + publications`. + * Por eso aquí basta con una sola llamada y, una vez que tenemos el + * bundle, navegamos al dashboard pasándoselo por `state` para evitar + * la doble petición. */ export function LandingPage() { const navigate = useNavigate(); @@ -34,8 +41,8 @@ export function LandingPage() { } setValidating(true); try { - await validateOrcid(orcidInput); - navigate(`/dashboard/${orcidInput}`); + const bundle = await searchResearcher(orcidInput); + navigate(`/dashboard/${orcidInput}`, { state: { bundle } }); } catch (err) { toast.error("No se pudo validar el ORCID iD", { description: err?.message ?? "Inténtalo de nuevo en unos segundos.", @@ -142,7 +149,7 @@ export function LandingPage() { } disabled:cursor-not-allowed`} > {validating && } - {validating ? "Validando..." : "Buscar"} + {validating ? "Buscando..." : "Buscar"} {error && ( diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index e2f1db9..44ea0bb 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -5,18 +5,22 @@ * y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla * decide cómo mostrarlo (toast, error inline, reintento, …). * - * 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. + * Configuración: + * - `VITE_API_URL`: URL base del backend, ya con el prefijo `/api` + * (p. ej. `http://localhost:8000/api`). Si se deja vacío, las + * peticiones se hacen contra `/api` y las redirige el proxy de + * Vite (ver `vite.config.js`). + * - `VITE_API_KEY`: clave compartida con el backend, se manda en el + * header `X-API-Key` de TODAS las peticiones. * - * 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 + * Contrato actual del backend (todo bajo `/api`): + * - GET /researchers/search → buscador grupal (todo en uno) + * - GET /researchers/search/{orcid_id} → buscador individual (todo en uno) + * - POST /researchers/{orcid_id}/sync → re-sync manual + * - POST /export/sword/publications body=[ids] → SWORD XML de selección + * - POST /export/zip/publications body=[ids] → ZIP de selección + * - GET /export/sword/researcher/{orcid_id} → SWORD XML de todo el investigador + * - GET /export/zip/researcher/{orcid_id} → ZIP de todo el investigador */ import { @@ -26,7 +30,12 @@ import { mockValidateOrcid, } from "./mocks"; -const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); +// `VITE_API_URL` puede venir como "" (vacío) en `.env` para usar el proxy +// de Vite. En ese caso no queremos usar string vacío como base, sino `/api`. +const BASE_URL = (import.meta.env.VITE_API_URL + ? import.meta.env.VITE_API_URL + : "/api").replace(/\/$/, ""); +const API_KEY = import.meta.env.VITE_API_KEY ?? ""; const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true"; @@ -39,16 +48,33 @@ export class ApiError extends Error { } } +/** + * Construye la cabecera base que llevan TODAS las peticiones (incluidas + * las descargas de blob). Si la API key está sin definir lo avisamos en + * consola para no fallar silenciosamente con un 401 críptico. + */ +function buildAuthHeaders(extra = {}) { + if (!API_KEY && import.meta.env.DEV) { + console.warn( + "[api] VITE_API_KEY no está definida; las peticiones serán rechazadas por el backend.", + ); + } + return { + Accept: "application/json", + ...(API_KEY ? { "X-API-Key": API_KEY } : {}), + ...extra, + }; +} + async function request(path, { method = "GET", body, signal, headers } = {}) { const url = `${BASE_URL}${path}`; const init = { method, signal, - headers: { - Accept: "application/json", + headers: buildAuthHeaders({ ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...headers, - }, + }), }; if (body !== undefined) init.body = JSON.stringify(body); @@ -89,87 +115,216 @@ async function request(path, { method = "GET", body, signal, headers } = {}) { /** * Adapta el esquema del backend (`pub_year`, campos opcionalmente `null`) * al que espera la UI (`publication_year`, strings seguras para filtrar). + * + * Mantenemos también los campos crudos relevantes (`put_code`, `subtitle`, + * `citation_value`, …) por si una vista futura los necesita sin tener + * que volver a tocar este mapper. */ function normalizePublication(p) { return { id: p.id, put_code: p.put_code ?? null, title: p.title || "Sin título", + subtitle: p.subtitle ?? null, journal: p.journal || "", doi: p.doi || "", publication_year: p.pub_year ?? null, + publication_month: p.pub_month ?? null, + publication_day: p.pub_day ?? null, type: p.type || null, + url: p.url ?? null, + short_description: p.short_description ?? null, + citation_type: p.citation_type ?? null, + citation_value: p.citation_value ?? null, + language_code: p.language_code ?? null, + country: p.country ?? null, + external_ids: Array.isArray(p.external_ids) ? p.external_ids : [], + contributors: Array.isArray(p.contributors) ? p.contributors : [], hash_fingerprint: p.hash_fingerprint ?? null, last_modified: p.last_modified ?? null, + status: p.status ?? null, + }; +} + +/** + * Normaliza la respuesta unificada `{ researcher, publications, … }` que + * devuelven tanto el buscador individual como el endpoint de sync. + * Devuelve siempre la misma forma para que las pantallas no tengan que + * conocer detalles del backend. + */ +function normalizeResearcherBundle(raw) { + if (!raw || typeof raw !== "object") { + return { + researcher: null, + publications: [], + newRecords: 0, + updatedRecords: 0, + unchangedRecords: 0, + totalRecords: 0, + }; + } + const publications = Array.isArray(raw.publications) + ? raw.publications.map(normalizePublication) + : []; + return { + researcher: raw.researcher ?? null, + publications, + newRecords: raw.new_records ?? 0, + updatedRecords: raw.updated_records ?? 0, + unchangedRecords: raw.unchanged_records ?? 0, + totalRecords: raw.total_records ?? publications.length, }; } /* ───────────────────────────── Endpoints ─────────────────────────────── */ /** - * 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.). + * Búsqueda "todo en uno" para 1 investigador. */ -export async function validateOrcid(orcidId, { signal } = {}) { - if (USE_MOCKS) return mockValidateOrcid(orcidId); +export async function searchResearcher(orcidId, { signal } = {}) { + if (USE_MOCKS) { + const researcher = await mockValidateOrcid(orcidId); + const publications = await mockGetPublications(orcidId); + return { + researcher, + publications, + newRecords: 0, + updatedRecords: 0, + unchangedRecords: publications.length, + totalRecords: publications.length, + }; + } - await request( - `/researchers/?orcid_id=${encodeURIComponent(orcidId)}`, - { method: "POST", signal }, - ); - return request(`/researchers/${encodeURIComponent(orcidId)}`, { signal }); + const batch = await searchResearchersBulk([orcidId], { signal }); + const first = batch.results?.[0] ?? null; + if (first) return first; + + const firstError = batch.errors?.[0]; + throw new ApiError(firstError?.detail ?? "No se pudo validar el ORCID iD.", { + payload: firstError, + }); } -/** GET /researchers/{orcid}/publications — normalizado para la UI. */ -export async function getPublications(orcidId, { signal } = {}) { - if (USE_MOCKS) return mockGetPublications(orcidId); +/** + * Búsqueda grupal: devuelve `results[]` (uno por cada ORCID) junto a + * `errors[]` y contadores. + * + * Contrato backend: + * POST /researchers/search + * body: { "orcid_ids": ["id1", "id2"] } + */ +export async function searchResearchersBulk(orcidIds, { signal } = {}) { + const ids = Array.isArray(orcidIds) ? orcidIds : [orcidIds]; + if (USE_MOCKS) { + const results = []; + for (const id of ids) { + const researcher = await mockValidateOrcid(id); + const publications = await mockGetPublications(id); + results.push({ + researcher, + publications, + newRecords: 0, + updatedRecords: 0, + unchangedRecords: publications.length, + totalRecords: publications.length, + }); + } + return { + results, + errors: [], + totalRequested: ids.length, + totalProcessed: results.length, + }; + } - const raw = await request( - `/researchers/${encodeURIComponent(orcidId)}/publications`, - { signal }, - ); - return Array.isArray(raw) ? raw.map(normalizePublication) : []; + const raw = await request(`/researchers/search`, { + method: "POST", + body: { orcid_ids: ids }, + signal, + }); + + const results = Array.isArray(raw?.results) + ? raw.results.map(normalizeResearcherBundle) + : []; + + return { + results, + errors: Array.isArray(raw?.errors) ? raw.errors : [], + totalRequested: raw?.total_requested ?? ids.length, + totalProcessed: raw?.total_processed ?? results.length, + }; } /** * 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. + * Ahora devuelve el bundle completo `{ researcher, publications, + * new_records, updated_records, unchanged_records, total_records }`, + * así que el caller puede refrescar el dashboard sin volver a pedir + * las publicaciones por separado. */ -export function syncResearcher(orcidId, { signal } = {}) { - if (USE_MOCKS) return mockSyncResearcher(orcidId); +export async function syncResearcher(orcidId, { signal } = {}) { + if (USE_MOCKS) { + const summary = await mockSyncResearcher(orcidId); + const publications = await mockGetPublications(orcidId); + return { + researcher: { orcid_id: orcidId }, + publications, + newRecords: summary?.new_records ?? 0, + updatedRecords: summary?.updated_records ?? 0, + unchangedRecords: 0, + totalRecords: summary?.total ?? publications.length, + }; + } - return request(`/researchers/${encodeURIComponent(orcidId)}/sync`, { - method: "POST", - signal, - }); + const raw = await request( + `/researchers/${encodeURIComponent(orcidId)}/sync`, + { method: "POST", signal }, + ); + return normalizeResearcherBundle(raw); +} + +/* ───────────────────────────── Exportación ───────────────────────────── */ + +/** + * Mapa de formatos UI → segmento del path en el backend. + * `xml` mantiene el nombre histórico que ya usaba la UI (`SWORD XML`) + * pero apunta al endpoint nuevo `/export/sword/...`. + */ +const EXPORT_PATH_SEGMENT = { + xml: "sword", + zip: "zip", +}; + +function exportSegmentFor(format) { + const segment = EXPORT_PATH_SEGMENT[format]; + if (!segment) throw new ApiError(`Formato de exportación no soportado: ${format}`); + return segment; } /** - * Construye la URL pública de exportación para enlaces directos - * (sin pasar por `fetch`). La usa el dropdown de exportación. + * URL pública del endpoint que descarga TODO el investigador + * (`GET /export/{sword|zip}/researcher/{orcid_id}`). La usamos como + * dato meramente informativo en los toasts de éxito; las descargas + * reales se disparan vía blob para poder forzar el download. + * + * Ojo: estas URLs requieren `X-API-Key`, así que NO sirven como link + * directo en una etiqueta ``; las exponemos para mostrarlas o + * loguearlas, no para navegar. */ export function getExportUrl(orcidId, format) { - return `${BASE_URL}/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`; + const segment = exportSegmentFor(format); + return `${BASE_URL}/export/${segment}/researcher/${encodeURIComponent(orcidId)}`; } /** * Descarga una exportación como Blob (para forzar descarga programática). * - * `publicationIds` es opcional; si se pasa un array no vacío, el backend - * filtra el export a sólo esas publicaciones (exportación selectiva). Si - * se omite o va vacío/null, se exporta el conjunto completo. - * - * Usamos POST (no GET) porque los IDs pueden ser cientos y no caben - * cómodamente en la query-string. + * - Si `publicationIds` viene con un array no vacío usamos el endpoint + * selectivo `POST /export/{sword|zip}/publications` con body + * `["id1", "id2", ...]` (array crudo, tal como espera el backend). + * - Si viene vacío/undefined usamos el endpoint masivo + * `GET /export/{sword|zip}/researcher/{orcid_id}` y descargamos todo. * * Lanza `ApiError` en fallo. */ @@ -183,23 +338,29 @@ export async function downloadExport( return { blob: null, url: getExportUrl(orcidId, format) }; } - const url = getExportUrl(orcidId, format); + const segment = exportSegmentFor(format); const ids = Array.isArray(publicationIds) && publicationIds.length > 0 ? publicationIds : null; + const url = ids + ? `${BASE_URL}/export/${segment}/publications` + : `${BASE_URL}/export/${segment}/researcher/${encodeURIComponent(orcidId)}`; + + const init = { + method: ids ? "POST" : "GET", + signal, + headers: buildAuthHeaders({ + Accept: "*/*", + ...(ids ? { "Content-Type": "application/json" } : {}), + }), + }; + if (ids) init.body = JSON.stringify(ids); + let response; try { - response = await fetch(url, { - method: "POST", - signal, - headers: { - "Content-Type": "application/json", - Accept: "*/*", - }, - body: JSON.stringify({ publication_ids: ids }), - }); + response = await fetch(url, init); } catch (cause) { if (cause?.name === "AbortError") throw cause; throw new ApiError("No se pudo contactar con el servidor.", { @@ -208,9 +369,19 @@ export async function downloadExport( }); } if (!response.ok) { + let payload = null; + try { + payload = await response.json(); + } catch { + /* sin cuerpo JSON */ + } + const detail = + payload?.detail ?? payload?.message ?? response.statusText ?? "Error"; throw new ApiError( - `No se pudo exportar el fichero ${format.toUpperCase()}.`, - { status: response.status }, + typeof detail === "string" + ? detail + : `No se pudo exportar el fichero ${format.toUpperCase()}.`, + { status: response.status, payload }, ); } const blob = await response.blob(); diff --git a/frontend/src/services/mocks.js b/frontend/src/services/mocks.js index a730ac8..4c475de 100644 --- a/frontend/src/services/mocks.js +++ b/frontend/src/services/mocks.js @@ -1,12 +1,20 @@ /** - * Temporary in-memory fixtures used while the FastAPI backend is still being - * built by the backend team. Once the real endpoints are live, the - * `useMockApi` flag in `api.js` callers can be flipped off and this file - * can be deleted. + * Temporary in-memory fixtures used while el backend está apagado o + * mientras se trabaja sin red. Se activan poniendo + * `VITE_USE_MOCKS=true` en `.env`. Una vez el backend esté siempre + * disponible, este fichero puede borrarse junto a las ramas + * `if (USE_MOCKS) …` de `api.js`. + * + * Los objetos siguen la forma que la UI espera (post-normalización), + * porque las funciones de `api.js` los devuelven directamente sin + * volver a pasar por el mapper. Si en el futuro queremos imitar el + * payload crudo del backend (`pub_year`, etc.), habrá que hacerlas + * pasar por `normalizePublication` en el lado del servicio. */ export const MOCK_RESEARCHER = { orcid_id: "0000-0002-1234-5678", name: "Dra. María García", + authenticated: false, affiliation: "Universidad Complutense de Madrid", last_sync_at: "2026-04-15T10:30:00Z", }; @@ -14,44 +22,54 @@ export const MOCK_RESEARCHER = { export const MOCK_PUBLICATIONS = [ { id: "uuid-1", + put_code: 1000001, title: "Machine Learning in Quantum Computing", journal: "Nature Physics", publication_year: 2025, doi: "10.1038/s41567-025-xxxx", type: "journal-article", + last_modified: "2025-09-01T10:00:00Z", }, { id: "uuid-2", + put_code: 1000002, title: "A review of SWORD protocol integrations in institutional repositories", journal: "Journal of Digital Repositories", publication_year: 2024, doi: "10.1000/jdr.2024.12", type: "review", + last_modified: "2024-11-12T09:00:00Z", }, { id: "uuid-3", + put_code: 1000003, title: "Open Access Policies and Compliance in European Universities", journal: "Scientometrics", publication_year: 2024, doi: "10.1007/s11192-024-04801-z", type: "journal-article", + last_modified: "2024-06-20T15:30:00Z", }, { id: "uuid-4", + put_code: 1000004, title: "Automated Metadata Harvesting via OAI-PMH", journal: "Digital Libraries Conference Proceedings", publication_year: 2023, doi: "10.1145/3587-dl.2023.09", type: "conference-paper", + last_modified: "2023-10-05T11:45:00Z", }, { id: "uuid-5", + put_code: 1000005, title: "Interoperability Standards for Research Information Systems", journal: "International Journal of Library Science", publication_year: 2023, doi: "10.1016/j.ijls.2023.03.011", type: "journal-article", + last_modified: "2023-04-18T08:15:00Z", }, ]; @@ -69,7 +87,9 @@ 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). + // Imita el resumen del SyncJob real (`new_records`, `updated_records`, + // `total`). El bundle completo lo reconstruye `api.js` a partir de + // este objeto + las publicaciones mock. return { status: "ok", message: "Sincronización completada correctamente.", diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 1396d0d..2307b97 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -13,7 +13,10 @@ export default defineConfig(({ mode }) => { host: true, port: 5173, proxy: { - '/researchers': { + // El backend agrupa todo bajo /api (researchers, export, …). + // Con un único prefijo evitamos tener que mantener una entrada + // por router cada vez que se añada un endpoint nuevo. + '/api': { target: proxyTarget, changeOrigin: true, },