feat: implement unified researcher search and enhance dashboard functionality
- Updated the API service to include a new `searchResearcher` function for streamlined researcher data retrieval. - Modified `LandingPage` to utilize the new search functionality, reducing the number of API calls. - Refactored `DashboardPage` to handle the new data structure returned from the search, improving loading efficiency and user experience. - Enhanced `vite.config.js` and `.env.example` for better API integration and development setup. - Added health checks in `docker-compose.yml` for database and Redis services to ensure service reliability.
This commit is contained in:
@@ -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/
|
||||
+13
-9
@@ -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
|
||||
|
||||
@@ -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 <Navigate to="/" replace />;
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 && <Spinner size={14} />}
|
||||
{validating ? "Validando..." : "Buscar"}
|
||||
{validating ? "Buscando..." : "Buscar"}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
+239
-68
@@ -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 `<a href>`; 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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user