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