diff --git a/backend/app/db/session.py b/backend/app/db/session.py index afdd051..d829fb1 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -64,10 +64,18 @@ def init_db(): def _ensure_columns(): insp = inspect(engine) - if "publications" in insp.get_table_names(): + table_names = set(insp.get_table_names()) + + if "publications" in table_names: cols = {c["name"] for c in insp.get_columns("publications")} if "downloaded" not in cols: with engine.begin() as conn: conn.execute( text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE") ) + + # Per-user download tracking (PublicationDownload model). + if "publication_downloads" not in table_names: + from app.db.models import PublicationDownload # noqa: F401 + + PublicationDownload.__table__.create(bind=engine, checkfirst=True) diff --git a/backend/app/security/export_auth.py b/backend/app/security/export_auth.py index 6451a45..2643810 100644 --- a/backend/app/security/export_auth.py +++ b/backend/app/security/export_auth.py @@ -18,17 +18,23 @@ def require_export_access( api_key: str | None = Depends(api_key_header), current: Researcher | None = Depends(get_optional_current_researcher), ) -> Researcher | None: - if api_key is not None: - if not is_valid_api_key(api_key): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid API key", - ) - return current + """ + Allow export when the proxy supplies a valid API key and/or the user + sends a valid Bearer token. Prefer returning `current` when both are + present so per-user download tracking is recorded on export. + """ + if api_key is not None and not is_valid_api_key(api_key): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + ) if current is not None: return current + if api_key is not None: + return None + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key", diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx index 7142cd8..1b39269 100644 --- a/frontend/src/components/dashboard/PublicationsTable.jsx +++ b/frontend/src/components/dashboard/PublicationsTable.jsx @@ -444,7 +444,7 @@ export function PublicationsTable({ className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent" > - Nuevo + NUEVO )}

@@ -570,7 +570,7 @@ export function PublicationsTable({ className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent" > - Nuevo + NUEVO )} {pub.title} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 23b19ee..fb2fd29 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -21,6 +21,10 @@ import { swordXmlFilename, } from "../utils/exportProfiles"; import { useAuth } from "../contexts/AuthContext"; +import { + markPublicationsAsDownloaded, + publicationsNeedDownloadFlags, +} from "../utils/downloadTracking"; const SUCCESS_FLASH_MS = 3000; /** Minimum gap between sync requests (protects backend + avoids toast spam). */ @@ -48,6 +52,7 @@ export function DashboardPage() { const { isAuthenticated } = useAuth(); const initialBundleRef = useRef(location.state?.bundle ?? null); + const consumedInitialBundleRef = useRef(false); const initialBundle = initialBundleRef.current; const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null); @@ -115,14 +120,25 @@ export function DashboardPage() { useEffect(() => { if (!isValidOrcid(orcid)) return; - if (initialBundleRef.current) { + + const cachedBundle = initialBundleRef.current; + if (cachedBundle && !consumedInitialBundleRef.current) { + consumedInitialBundleRef.current = true; initialBundleRef.current = null; - return; + if ( + !publicationsNeedDownloadFlags( + cachedBundle.publications, + isAuthenticated, + ) + ) { + return; + } } + const ctrl = new AbortController(); loadBundle(ctrl.signal); return () => ctrl.abort(); - }, [orcid, loadBundle]); + }, [orcid, loadBundle, isAuthenticated]); useEffect(() => { return () => { @@ -270,6 +286,10 @@ export function DashboardPage() { } else { scope = "todo el investigador"; } + if (isAuthenticated && ids?.length) { + setPublications((prev) => markPublicationsAsDownloaded(prev, ids)); + } + toast.success(`Exportación ${format.toUpperCase()} completada`, { id: EXPORT_TOAST_ID, description: scope, diff --git a/frontend/src/pages/GroupResultsPage.jsx b/frontend/src/pages/GroupResultsPage.jsx index 728eb2e..3faa41b 100644 --- a/frontend/src/pages/GroupResultsPage.jsx +++ b/frontend/src/pages/GroupResultsPage.jsx @@ -22,6 +22,7 @@ import { } from "../utils/exportProfiles"; import { ExportDropdown } from "../components/dashboard/ExportDropdown"; import { useAuth } from "../contexts/AuthContext"; +import { markGroupResultsAsDownloaded } from "../utils/downloadTracking"; const EXPORT_COOLDOWN_MS = 5000; const GLOBAL_EXPORT_TOAST_ID = "group-export-global"; @@ -108,7 +109,7 @@ export function GroupResultsPage() { })(); return () => ctrl.abort(); - }, [orcidIds, navigate]); + }, [orcidIds, navigate, isAuthenticated]); useEffect(() => { const cardTimersObj = cardExportCooldownTimerRef.current; @@ -218,6 +219,10 @@ export function GroupResultsPage() { anchor.remove(); URL.revokeObjectURL(objectUrl); } + if (isAuthenticated) { + setResults((prev) => markGroupResultsAsDownloaded(prev, ids)); + } + toast.success(`Exportación ${format.toUpperCase()} completada`, { id: GLOBAL_EXPORT_TOAST_ID, description: `${ids.length} publicaciones exportadas.`, @@ -276,6 +281,10 @@ export function GroupResultsPage() { anchor.remove(); URL.revokeObjectURL(objectUrl); } + if (isAuthenticated) { + setResults((prev) => markGroupResultsAsDownloaded(prev, ids)); + } + toast.success(`Exportación ${format.toUpperCase()} completada`, { id: `group-export-card-${orcidId}`, description: `${ids.length} publicaciones de ${orcidId}.`, diff --git a/frontend/src/services/mocks.js b/frontend/src/services/mocks.js index 4c475de..620272f 100644 --- a/frontend/src/services/mocks.js +++ b/frontend/src/services/mocks.js @@ -29,6 +29,7 @@ export const MOCK_PUBLICATIONS = [ doi: "10.1038/s41567-025-xxxx", type: "journal-article", last_modified: "2025-09-01T10:00:00Z", + downloaded_by_me: false, }, { id: "uuid-2", @@ -40,6 +41,7 @@ export const MOCK_PUBLICATIONS = [ doi: "10.1000/jdr.2024.12", type: "review", last_modified: "2024-11-12T09:00:00Z", + downloaded_by_me: false, }, { id: "uuid-3", @@ -50,6 +52,7 @@ export const MOCK_PUBLICATIONS = [ doi: "10.1007/s11192-024-04801-z", type: "journal-article", last_modified: "2024-06-20T15:30:00Z", + downloaded_by_me: true, }, { id: "uuid-4", @@ -60,6 +63,7 @@ export const MOCK_PUBLICATIONS = [ doi: "10.1145/3587-dl.2023.09", type: "conference-paper", last_modified: "2023-10-05T11:45:00Z", + downloaded_by_me: false, }, { id: "uuid-5", @@ -70,6 +74,7 @@ export const MOCK_PUBLICATIONS = [ doi: "10.1016/j.ijls.2023.03.011", type: "journal-article", last_modified: "2023-04-18T08:15:00Z", + downloaded_by_me: true, }, ]; diff --git a/frontend/src/utils/downloadTracking.js b/frontend/src/utils/downloadTracking.js new file mode 100644 index 0000000..58ea8ce --- /dev/null +++ b/frontend/src/utils/downloadTracking.js @@ -0,0 +1,32 @@ +/** + * Helpers for per-user "new publication" (not yet downloaded) tracking. + * Backend sets `downloaded_by_me` when a Bearer token is present; these + * utilities keep the dashboard in sync after login and export. + */ + +/** True when an authenticated user needs a refetch to obtain download flags. */ +export function publicationsNeedDownloadFlags(publications, isAuthenticated) { + if (!isAuthenticated || !publications?.length) return false; + return publications.some((p) => p.downloaded_by_me == null); +} + +/** Mark the given publication IDs as downloaded in local state. */ +export function markPublicationsAsDownloaded(publications, downloadedIds) { + if (!downloadedIds?.length || !publications?.length) return publications; + const ids = new Set(downloadedIds); + return publications.map((p) => + ids.has(p.id) ? { ...p, downloaded_by_me: true } : p, + ); +} + +/** Apply download flags across group-search result bundles. */ +export function markGroupResultsAsDownloaded(results, downloadedIds) { + if (!downloadedIds?.length || !results?.length) return results; + const ids = new Set(downloadedIds); + return results.map((bundle) => ({ + ...bundle, + publications: (bundle.publications ?? []).map((p) => + ids.has(p.id) ? { ...p, downloaded_by_me: true } : p, + ), + })); +}