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