Added run state front end support

Updated scholars layout to match dashboard
This commit is contained in:
Justin Visser 2026-02-19 20:07:08 +01:00
parent e49e753023
commit 110e246a1c
10 changed files with 1095 additions and 284 deletions

View file

@ -72,6 +72,17 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down
- `CONTRIBUTING.md`: contribution policy and merge checklist. - `CONTRIBUTING.md`: contribution policy and merge checklist.
- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI. - `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI.
## Data Model Notes
- Scholar tracking is user-scoped: each account can track the same Scholar ID independently.
- Publications are shared/global records deduplicated by Scholar cluster ID and normalized fingerprint.
- Per-account visibility and read state is stored on scholar-publication links, not on the global publication row.
## Name Search Status
- Scholar name search is intentionally WIP in the UI.
- Current Google Scholar behavior often redirects name-search traffic to login/challenge flows, so production onboarding should use direct Scholar ID/profile URL adds.
## Environment Variables (Complete Reference) ## Environment Variables (Complete Reference)
Notes: Notes:

View file

@ -1,5 +1,6 @@
import { setCsrfTokenProvider } from "@/lib/api/client"; import { setCsrfTokenProvider } from "@/lib/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { useThemeStore } from "@/stores/theme"; import { useThemeStore } from "@/stores/theme";
import { useUserSettingsStore } from "@/stores/user_settings"; import { useUserSettingsStore } from "@/stores/user_settings";
@ -12,9 +13,12 @@ export async function bootstrapAppProviders(): Promise<void> {
await auth.bootstrapSession(); await auth.bootstrapSession();
const userSettings = useUserSettingsStore(); const userSettings = useUserSettingsStore();
const runStatus = useRunStatusStore();
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
await userSettings.bootstrap(); await userSettings.bootstrap();
await runStatus.bootstrap();
} else { } else {
userSettings.reset(); userSettings.reset();
runStatus.reset();
} }
} }

View file

@ -2,11 +2,14 @@
import { computed } from "vue"; import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings"; import { useUserSettingsStore } from "@/stores/user_settings";
const auth = useAuthStore(); const auth = useAuthStore();
const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore(); const userSettings = useUserSettingsStore();
const router = useRouter(); const router = useRouter();
@ -28,6 +31,21 @@ const links = computed(() => {
return userSettings.isPageVisible(item.id); return userSettings.isPageVisible(item.id);
}); });
}); });
const navRunStatus = computed(() => {
if (runStatus.isLikelyRunning) {
return "running";
}
return "idle";
});
const navRunText = computed(() => {
const label = navRunStatus.value
.replaceAll("_", " ")
.split(" ")
.filter((segment) => segment.length > 0)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" ");
return `State: ${label}`;
});
async function onLogout(): Promise<void> { async function onLogout(): Promise<void> {
await auth.logout(); await auth.logout();
@ -53,7 +71,13 @@ async function onLogout(): Promise<void> {
</nav> </nav>
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3"> <div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
<p class="truncate text-xs text-ink-muted">{{ auth.user?.email }}</p> <div
v-if="auth.isAdmin"
class="flex items-center justify-between gap-2 rounded-lg border border-stroke-default bg-surface-card-muted px-2.5 py-2 text-xs text-secondary"
>
<span class="truncate">{{ navRunText }}</span>
<RunStatusBadge :status="navRunStatus" />
</div>
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton> <AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
</div> </div>
</div> </div>

View file

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard"; import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
import { triggerManualRun } from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue"; import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
@ -14,14 +13,67 @@ import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
const loading = ref(true); const loading = ref(true);
const pendingRun = ref(false);
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null); const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null); const successMessage = ref<string | null>(null);
const snapshot = ref<DashboardSnapshot | null>(null); const snapshot = ref<DashboardSnapshot | null>(null);
const auth = useAuthStore(); const auth = useAuthStore();
const runStatus = useRunStatusStore();
const startCheckLabel = computed(() => {
if (runStatus.isLikelyRunning) {
return "Check in progress";
}
if (runStatus.isSubmitting) {
return "Starting...";
}
return "Start check";
});
const displayedLatestRun = computed(() => {
const snapshotLatest = snapshot.value?.latestRun ?? null;
const sharedLatest = runStatus.latestRun;
if (!snapshotLatest) {
return sharedLatest;
}
if (!sharedLatest) {
return snapshotLatest;
}
if (sharedLatest.id > snapshotLatest.id) {
return sharedLatest;
}
if (sharedLatest.id < snapshotLatest.id) {
return snapshotLatest;
}
return sharedLatest;
});
const recentRuns = computed(() => {
const baseRuns = snapshot.value?.recentRuns ?? [];
const mergedRuns = [...baseRuns];
const latest = displayedLatestRun.value;
if (!latest) {
return mergedRuns;
}
const existingIndex = mergedRuns.findIndex((run) => run.id === latest.id);
if (existingIndex === -1) {
mergedRuns.unshift(latest);
return mergedRuns;
}
mergedRuns.splice(existingIndex, 1, latest);
return mergedRuns;
});
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
const showRunningHint = computed(
() => runStatus.isLikelyRunning && displayedLatestRun.value?.status !== "running",
);
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) { if (!value) {
@ -40,7 +92,9 @@ async function loadSnapshot(): Promise<void> {
errorRequestId.value = null; errorRequestId.value = null;
try { try {
snapshot.value = await fetchDashboardSnapshot(); const dashboardSnapshot = await fetchDashboardSnapshot();
snapshot.value = dashboardSnapshot;
runStatus.setLatestRun(dashboardSnapshot.latestRun);
} catch (error) { } catch (error) {
snapshot.value = null; snapshot.value = null;
if (error instanceof ApiRequestError) { if (error instanceof ApiRequestError) {
@ -55,24 +109,31 @@ async function loadSnapshot(): Promise<void> {
} }
async function onTriggerRun(): Promise<void> { async function onTriggerRun(): Promise<void> {
pendingRun.value = true;
errorMessage.value = null; errorMessage.value = null;
errorRequestId.value = null; errorRequestId.value = null;
successMessage.value = null; successMessage.value = null;
try { try {
const result = await triggerManualRun(); const result = await runStatus.startManualCheck();
successMessage.value = `Update check #${result.run_id} started successfully.`; if (result.kind === "started") {
successMessage.value = `Update check #${result.runId} started successfully.`;
await loadSnapshot(); await loadSnapshot();
} catch (error) { return;
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to start an update check.";
} }
} finally {
pendingRun.value = false; if (result.kind === "already_running") {
successMessage.value = result.runId
? `Update check #${result.runId} is already in progress.`
: "An update check is already in progress.";
await loadSnapshot();
return;
}
errorMessage.value = result.message;
errorRequestId.value = result.requestId;
} catch {
errorMessage.value = "Unable to start an update check.";
errorRequestId.value = null;
} }
} }
@ -166,7 +227,7 @@ onMounted(() => {
</article> </article>
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"> <article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
<p class="text-xs uppercase tracking-wide text-ink-muted">Scholars processed</p> <p class="text-xs uppercase tracking-wide text-ink-muted">Scholars processed</p>
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.latestRun?.scholar_count ?? 0 }}</p> <p class="mt-1 text-xl font-semibold text-ink-primary">{{ displayedLatestRun?.scholar_count ?? 0 }}</p>
</article> </article>
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"> <article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
<p class="text-xs uppercase tracking-wide text-ink-muted">Queue pressure</p> <p class="text-xs uppercase tracking-wide text-ink-muted">Queue pressure</p>
@ -193,10 +254,10 @@ onMounted(() => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AppButton <AppButton
v-if="auth.isAdmin" v-if="auth.isAdmin"
:disabled="pendingRun" :disabled="runStatus.isRunActive"
@click="onTriggerRun" @click="onTriggerRun"
> >
{{ pendingRun ? "Starting..." : "Start check" }} {{ startCheckLabel }}
</AppButton> </AppButton>
<RouterLink <RouterLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
@ -209,26 +270,48 @@ onMounted(() => {
</div> </div>
<div <div
v-if="snapshot.latestRun" v-if="showRunningHint"
class="rounded-xl border border-state-info-border bg-state-info-bg px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<RunStatusBadge status="running" />
<span class="text-sm font-semibold text-state-info-text">Check in progress</span>
</div>
<RouterLink
v-if="auth.isAdmin && activeRunId"
:to="`/admin/runs/${activeRunId}`"
class="link-inline text-xs"
>
View live check details
</RouterLink>
</div>
<p class="mt-1 text-sm text-state-info-text">
A publication check has started. Results and counts update after it finishes.
</p>
</div>
<div
v-if="displayedLatestRun"
class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2" class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
> >
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<RunStatusBadge :status="snapshot.latestRun.status" /> <RunStatusBadge :status="displayedLatestRun.status" />
<span class="text-sm font-semibold text-ink-primary">Latest check #{{ snapshot.latestRun.id }}</span> <span class="text-sm font-semibold text-ink-primary">Latest check #{{ displayedLatestRun.id }}</span>
</div> </div>
<RouterLink <RouterLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
:to="`/admin/runs/${snapshot.latestRun.id}`" :to="`/admin/runs/${displayedLatestRun.id}`"
class="link-inline text-xs" class="link-inline text-xs"
> >
View diagnostics View diagnostics
</RouterLink> </RouterLink>
</div> </div>
<p class="mt-2 text-sm text-secondary"> <p class="mt-2 text-sm text-secondary">
Started {{ formatDate(snapshot.latestRun.start_dt) }}. Processed Started {{ formatDate(displayedLatestRun.start_dt) }}. Processed
{{ snapshot.latestRun.scholar_count }} scholars and discovered {{ displayedLatestRun.scholar_count }} scholars and discovered
{{ snapshot.latestRun.new_publication_count }} new publications. {{ displayedLatestRun.new_publication_count }} new publications.
</p> </p>
</div> </div>
<AppEmptyState <AppEmptyState
@ -240,13 +323,13 @@ onMounted(() => {
<div class="flex min-h-0 flex-1 flex-col gap-2 xl:overflow-hidden"> <div class="flex min-h-0 flex-1 flex-col gap-2 xl:overflow-hidden">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Recent checks</p> <p class="text-xs font-semibold uppercase tracking-wide text-muted">Recent checks</p>
<AppEmptyState <AppEmptyState
v-if="snapshot.recentRuns.length === 0" v-if="recentRuns.length === 0"
title="No checks yet" title="No checks yet"
body="Check history will appear here." body="Check history will appear here."
/> />
<ul v-else class="grid min-h-0 flex-1 content-start gap-2 overflow-y-auto overscroll-contain pr-1"> <ul v-else class="grid min-h-0 flex-1 content-start gap-2 overflow-y-auto overscroll-contain pr-1">
<li <li
v-for="run in snapshot.recentRuns" v-for="run in recentRuns"
:key="run.id" :key="run.id"
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2" class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
> >

View file

@ -17,20 +17,20 @@ import {
listQueueItems, listQueueItems,
listRuns, listRuns,
retryQueueItem, retryQueueItem,
triggerManualRun,
type QueueItem, type QueueItem,
type RunListItem, type RunListItem,
} from "@/features/runs"; } from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { useRunStatusStore } from "@/stores/run_status";
const loading = ref(true); const loading = ref(true);
const pendingRun = ref(false);
const runs = ref<RunListItem[]>([]); const runs = ref<RunListItem[]>([]);
const queueItems = ref<QueueItem[]>([]); const queueItems = ref<QueueItem[]>([]);
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null); const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null); const successMessage = ref<string | null>(null);
const activeQueueItemId = ref<number | null>(null); const activeQueueItemId = ref<number | null>(null);
const runStatus = useRunStatusStore();
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) { if (!value) {
@ -58,6 +58,16 @@ function queueHealth() {
} }
const queueCounts = computed(() => queueHealth()); const queueCounts = computed(() => queueHealth());
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
const runButtonLabel = computed(() => {
if (runStatus.isLikelyRunning) {
return "Check in progress";
}
if (runStatus.isSubmitting) {
return "Starting...";
}
return "Run now";
});
async function loadData(): Promise<void> { async function loadData(): Promise<void> {
loading.value = true; loading.value = true;
@ -68,6 +78,7 @@ async function loadData(): Promise<void> {
const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]); const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
runs.value = loadedRuns; runs.value = loadedRuns;
queueItems.value = loadedQueue; queueItems.value = loadedQueue;
runStatus.setLatestRun(loadedRuns[0] ?? null);
} catch (error) { } catch (error) {
runs.value = []; runs.value = [];
queueItems.value = []; queueItems.value = [];
@ -83,22 +94,31 @@ async function loadData(): Promise<void> {
} }
async function onTriggerManualRun(): Promise<void> { async function onTriggerManualRun(): Promise<void> {
pendingRun.value = true;
successMessage.value = null; successMessage.value = null;
errorMessage.value = null;
errorRequestId.value = null;
try { try {
const result = await triggerManualRun(); const result = await runStatus.startManualCheck();
successMessage.value = `Manual run queued as #${result.run_id} (${result.status}).`; if (result.kind === "started") {
successMessage.value = `Update check #${result.runId} started successfully.`;
await loadData(); await loadData();
} catch (error) { return;
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to trigger manual run.";
} }
} finally {
pendingRun.value = false; if (result.kind === "already_running") {
successMessage.value = result.runId
? `Update check #${result.runId} is already in progress.`
: "An update check is already in progress.";
await loadData();
return;
}
errorMessage.value = result.message;
errorRequestId.value = result.requestId;
} catch {
errorMessage.value = "Unable to start an update check.";
errorRequestId.value = null;
} }
} }
@ -155,13 +175,23 @@ onMounted(() => {
/> />
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppButton :disabled="pendingRun" @click="onTriggerManualRun"> <AppButton :disabled="runStatus.isRunActive" @click="onTriggerManualRun">
{{ pendingRun ? "Triggering..." : "Run now" }} {{ runButtonLabel }}
</AppButton> </AppButton>
<AppButton variant="secondary" :disabled="loading" @click="loadData"> <AppButton variant="secondary" :disabled="loading" @click="loadData">
{{ loading ? "Refreshing..." : "Refresh" }} {{ loading ? "Refreshing..." : "Refresh" }}
</AppButton> </AppButton>
</div> </div>
<div
v-if="runStatus.isLikelyRunning"
class="flex flex-wrap items-center gap-2 rounded-xl border border-state-info-border bg-state-info-bg px-3 py-2 text-sm text-state-info-text"
>
<RunStatusBadge status="running" />
<span class="font-medium">A publication check is currently running.</span>
<RouterLink v-if="activeRunId" :to="`/admin/runs/${activeRunId}`" class="link-inline text-xs">
View live check details
</RouterLink>
</div>
</AppCard> </AppCard>
<RequestStateAlerts <RequestStateAlerts

View file

@ -11,6 +11,7 @@ import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue"; import AppModal from "@/components/ui/AppModal.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue"; import AppTable from "@/components/ui/AppTable.vue";
import { import {
clearScholarImage, clearScholarImage,
@ -42,6 +43,9 @@ const imageUrlDraftByScholarId = ref<Record<number, string>>({});
const scholarBatchInput = ref(""); const scholarBatchInput = ref("");
const searchQuery = ref(""); const searchQuery = ref("");
const trackedFilterQuery = ref("");
type TrackedScholarSort = "recent" | "name_asc" | "name_desc" | "enabled_first";
const trackedSort = ref<TrackedScholarSort>("recent");
const searchResult = ref<ScholarSearchResult | null>(null); const searchResult = ref<ScholarSearchResult | null>(null);
const searchErrorMessage = ref<string | null>(null); const searchErrorMessage = ref<string | null>(null);
const searchErrorRequestId = ref<string | null>(null); const searchErrorRequestId = ref<string | null>(null);
@ -58,6 +62,7 @@ const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => it
const activeScholarSettings = computed( const activeScholarSettings = computed(
() => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null, () => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null,
); );
const hasTrackedScholars = computed(() => scholars.value.length > 0);
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length); const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
const searchHasRun = computed(() => searchResult.value !== null); const searchHasRun = computed(() => searchResult.value !== null);
const searchIsDegraded = computed(() => { const searchIsDegraded = computed(() => {
@ -86,6 +91,50 @@ const emptySearchCandidates = computed(() => {
} }
return result.candidates.length === 0; return result.candidates.length === 0;
}); });
const normalizedTrackedFilter = computed(() => trackedFilterQuery.value.trim().toLocaleLowerCase());
const visibleScholars = computed(() => {
const filter = normalizedTrackedFilter.value;
const filtered = scholars.value.filter((item) => {
if (!filter) {
return true;
}
const label = scholarLabel(item).toLocaleLowerCase();
const scholarId = item.scholar_id.toLocaleLowerCase();
return label.includes(filter) || scholarId.includes(filter);
});
const byName = (a: ScholarProfile, b: ScholarProfile) =>
scholarLabel(a).localeCompare(scholarLabel(b), undefined, { sensitivity: "base" });
const sorted = [...filtered];
if (trackedSort.value === "name_asc") {
sorted.sort(byName);
return sorted;
}
if (trackedSort.value === "name_desc") {
sorted.sort((a, b) => byName(b, a));
return sorted;
}
if (trackedSort.value === "enabled_first") {
sorted.sort((a, b) => {
if (a.is_enabled !== b.is_enabled) {
return a.is_enabled ? -1 : 1;
}
return byName(a, b);
});
return sorted;
}
sorted.sort((a, b) => b.id - a.id);
return sorted;
});
const hasVisibleScholars = computed(() => visibleScholars.value.length > 0);
const trackedCountLabel = computed(() => {
if (!normalizedTrackedFilter.value) {
return `${scholars.value.length} tracked`;
}
return `${visibleScholars.value.length} of ${scholars.value.length}`;
});
function scholarProfileUrl(scholarId: string): string { function scholarProfileUrl(scholarId: string): string {
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`; return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
@ -464,7 +513,9 @@ onMounted(() => {
<AppPage <AppPage
title="Scholars" title="Scholars"
subtitle="Add and maintain the Google Scholar profiles you want Scholarr to monitor." subtitle="Add and maintain the Google Scholar profiles you want Scholarr to monitor."
fill
> >
<div class="flex min-h-0 flex-1 flex-col gap-4 xl:overflow-hidden">
<RequestStateAlerts <RequestStateAlerts
:success-message="successMessage" :success-message="successMessage"
success-title="Scholar update complete" success-title="Scholar update complete"
@ -474,9 +525,9 @@ onMounted(() => {
@dismiss-success="successMessage = null" @dismiss-success="successMessage = null"
/> />
<section class="grid gap-4 xl:grid-cols-2"> <section class="grid min-h-0 flex-1 gap-4 xl:h-full xl:grid-cols-2 xl:grid-rows-[minmax(0,1fr)] xl:overflow-hidden">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4 xl:order-2 xl:h-full xl:min-h-0 xl:grid-rows-[minmax(0,1fr)_minmax(0,1fr)] xl:overflow-hidden">
<AppCard class="space-y-4"> <AppCard class="space-y-4 xl:flex xl:min-h-0 xl:flex-col">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2> <h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
@ -510,16 +561,18 @@ onMounted(() => {
</form> </form>
</AppCard> </AppCard>
<AppCard class="relative space-y-4 select-none"> <AppCard class="relative space-y-4 select-none xl:flex xl:min-h-0 xl:flex-col xl:overflow-hidden">
<div class="flex flex-wrap items-start justify-between gap-2"> <div class="flex flex-wrap items-start justify-between gap-2">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2> <h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2>
<AppHelpHint text="This workflow is currently paused while anti-block reliability changes are finalized." /> <AppHelpHint text="Google Scholar now challenges this endpoint with account login and anti-bot checks, so this workflow stays disabled." />
</div> </div>
<p class="text-sm text-secondary">This helper is paused while reliability hardening is completed.</p> <p class="text-sm text-secondary">
This helper remains paused because Google Scholar currently requires account login for reliable name search access.
</p>
</div> </div>
<AppHelpHint text="Name search is not currently supported in production. It is on the roadmap and will return after reliability hardening."> <AppHelpHint text="Name search is kept as WIP. Use direct Scholar ID or profile URL adds for production tracking.">
<template #trigger> <template #trigger>
<AppBadge tone="warning">WIP</AppBadge> <AppBadge tone="warning">WIP</AppBadge>
</template> </template>
@ -540,9 +593,10 @@ onMounted(() => {
</AppButton> </AppButton>
</form> </form>
<p class="text-xs text-secondary"> <p class="text-xs text-secondary">
Direct Scholar ID/URL adds above remain the dependable path while this feature is in progress. Direct Scholar ID/URL adds above are the supported path until name search can run without login challenges.
</p> </p>
<div class="min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
<RequestStateAlerts <RequestStateAlerts
:error-message="searchErrorMessage" :error-message="searchErrorMessage"
:error-request-id="searchErrorRequestId" :error-request-id="searchErrorRequestId"
@ -625,13 +679,14 @@ onMounted(() => {
</AsyncStateGate> </AsyncStateGate>
</template> </template>
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary"> <p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
Run a name search to get candidates with one-click add actions. Name search remains disabled while login-gated responses are unresolved.
</p> </p>
</AsyncStateGate> </AsyncStateGate>
</div>
</AppCard> </AppCard>
</div> </div>
<AppCard class="space-y-4"> <AppCard class="flex min-h-0 flex-col gap-4 xl:order-1 xl:h-full xl:overflow-hidden">
<div class="space-y-3"> <div class="space-y-3">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -640,37 +695,64 @@ onMounted(() => {
text="Tracked scholars are active profile sources. Open Manage to control status, image overrides, and removal." text="Tracked scholars are active profile sources. Open Manage to control status, image overrides, and removal."
/> />
</div> </div>
<p class="text-sm text-secondary">Review tracked profiles and open per-scholar settings when needed.</p> <p class="text-sm text-secondary">Review tracked profiles, filter quickly, and open per-scholar settings when needed.</p>
</div> </div>
<div <div class="grid gap-3 rounded-xl border border-stroke-default bg-surface-card-muted/70 px-3 py-2">
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-stroke-default bg-surface-card-muted/70 px-3 py-2" <div class="flex flex-wrap items-center justify-between gap-3">
>
<p class="text-xs font-medium uppercase tracking-wide text-secondary">Tracking status</p> <p class="text-xs font-medium uppercase tracking-wide text-secondary">Tracking status</p>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span <span
class="inline-flex min-h-10 items-center rounded-lg border border-state-info-border bg-state-info-bg px-3 text-sm font-semibold text-state-info-text" class="inline-flex min-h-10 items-center rounded-lg border border-state-info-border bg-state-info-bg px-3 text-sm font-semibold text-state-info-text"
> >
{{ scholars.length }} tracked {{ trackedCountLabel }}
</span> </span>
<AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars"> <AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars">
{{ loading ? "Refreshing..." : "Refresh" }} {{ loading ? "Refreshing..." : "Refresh" }}
</AppButton> </AppButton>
</div> </div>
</div> </div>
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label class="grid gap-1 text-xs font-medium uppercase tracking-wide text-secondary" for="tracked-scholar-filter">
<span>Filter tracked scholars</span>
<AppInput
id="tracked-scholar-filter"
v-model="trackedFilterQuery"
placeholder="Filter by name or scholar ID"
/>
</label>
<label class="grid gap-1 text-xs font-medium uppercase tracking-wide text-secondary" for="tracked-scholar-sort">
<span>Sort</span>
<AppSelect id="tracked-scholar-sort" v-model="trackedSort">
<option value="recent">Recently added</option>
<option value="name_asc">Name (A-Z)</option>
<option value="name_desc">Name (Z-A)</option>
<option value="enabled_first">Enabled first</option>
</AppSelect>
</label>
</div>
</div>
</div> </div>
<div class="min-h-0 flex-1 xl:overflow-hidden">
<AsyncStateGate <AsyncStateGate
:loading="loading" :loading="loading"
:loading-lines="6" :loading-lines="6"
:empty="scholars.length === 0" :empty="!hasTrackedScholars"
:show-empty="!errorMessage" :show-empty="!errorMessage"
empty-title="No scholars tracked" empty-title="No scholars tracked"
empty-body="Add a Scholar ID or URL to start ingestion tracking." empty-body="Add a Scholar ID or URL to start ingestion tracking."
> >
<div class="space-y-3"> <AppEmptyState
v-if="!hasVisibleScholars"
title="No scholars match this filter"
body="Clear or adjust the filter to see tracked scholars."
/>
<div v-else class="space-y-3 xl:flex xl:h-full xl:min-h-0 xl:flex-col xl:space-y-0">
<ul class="grid gap-3 lg:hidden"> <ul class="grid gap-3 lg:hidden">
<li <li
v-for="item in scholars" v-for="item in visibleScholars"
:key="item.id" :key="item.id"
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3" class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
> >
@ -694,7 +776,8 @@ onMounted(() => {
</li> </li>
</ul> </ul>
<AppTable class="hidden lg:block" label="Tracked scholars table"> <div class="hidden min-h-0 flex-1 lg:block">
<AppTable class="max-h-full overflow-y-auto" label="Tracked scholars table">
<thead> <thead>
<tr> <tr>
<th scope="col">Scholar</th> <th scope="col">Scholar</th>
@ -702,7 +785,7 @@ onMounted(() => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in scholars" :key="item.id"> <tr v-for="item in visibleScholars" :key="item.id">
<td> <td>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" /> <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
@ -727,9 +810,12 @@ onMounted(() => {
</tbody> </tbody>
</AppTable> </AppTable>
</div> </div>
</div>
</AsyncStateGate> </AsyncStateGate>
</div>
</AppCard> </AppCard>
</section> </section>
</div>
<AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings"> <AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings">
<div v-if="activeScholarSettings" class="grid gap-4"> <div v-if="activeScholarSettings" class="grid gap-4">

View file

@ -3,6 +3,7 @@ import { defineStore } from "pinia";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { fetchCsrfBootstrap } from "@/lib/api/csrf"; import { fetchCsrfBootstrap } from "@/lib/api/csrf";
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session"; import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
import { useRunStatusStore } from "@/stores/run_status";
import { useUiStore } from "@/stores/ui"; import { useUiStore } from "@/stores/ui";
import { useUserSettingsStore } from "@/stores/user_settings"; import { useUserSettingsStore } from "@/stores/user_settings";
@ -55,7 +56,9 @@ export const useAuthStore = defineStore("auth", {
this.user = response.data.user; this.user = response.data.user;
this.csrfToken = response.data.csrf_token; this.csrfToken = response.data.csrf_token;
const userSettings = useUserSettingsStore(); const userSettings = useUserSettingsStore();
const runStatus = useRunStatusStore();
await userSettings.bootstrap(); await userSettings.bootstrap();
await runStatus.bootstrap();
}, },
async logout(): Promise<void> { async logout(): Promise<void> {
await logoutSession(); await logoutSession();
@ -63,7 +66,9 @@ export const useAuthStore = defineStore("auth", {
this.user = null; this.user = null;
this.csrfToken = null; this.csrfToken = null;
const userSettings = useUserSettingsStore(); const userSettings = useUserSettingsStore();
const runStatus = useRunStatusStore();
userSettings.reset(); userSettings.reset();
runStatus.reset();
try { try {
const csrf = await fetchCsrfBootstrap(); const csrf = await fetchCsrfBootstrap();

View file

@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { ApiRequestError } from "@/lib/api/errors";
vi.mock("@/features/runs", () => ({
listRuns: vi.fn(),
triggerManualRun: vi.fn(),
}));
import { listRuns, triggerManualRun } from "@/features/runs";
import {
RUN_STATUS_POLL_INTERVAL_MS,
RUN_STATUS_STARTING_PHASE_MS,
useRunStatusStore,
} from "@/stores/run_status";
function buildRun(overrides: Partial<{
id: number;
trigger_type: string;
status: string;
start_dt: string;
end_dt: string | null;
scholar_count: number;
new_publication_count: number;
failed_count: number;
partial_count: number;
}> = {}) {
return {
id: 1,
trigger_type: "manual",
status: "success",
start_dt: "2026-02-19T12:00:00Z",
end_dt: "2026-02-19T12:01:00Z",
scholar_count: 3,
new_publication_count: 2,
failed_count: 0,
partial_count: 0,
...overrides,
};
}
describe("run status store", () => {
const mockedListRuns = vi.mocked(listRuns);
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
beforeEach(() => {
setActivePinia(createPinia());
mockedListRuns.mockReset();
mockedTriggerManualRun.mockReset();
vi.useRealTimers();
});
afterEach(() => {
useRunStatusStore().reset();
vi.useRealTimers();
});
it("bootstraps from latest run and exposes idle start state", async () => {
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 11, status: "success" })]);
const store = useRunStatusStore();
await store.bootstrap();
expect(mockedListRuns).toHaveBeenCalledWith({ limit: 1 });
expect(store.latestRun?.id).toBe(11);
expect(store.canStart).toBe(true);
expect(store.isRunActive).toBe(false);
expect(store.isPolling).toBe(false);
});
it("starts manual checks and marks active state", async () => {
mockedTriggerManualRun.mockResolvedValueOnce({
run_id: 25,
status: "running",
scholar_count: 0,
succeeded_count: 0,
failed_count: 0,
partial_count: 0,
new_publication_count: 0,
reused_existing_run: false,
idempotency_key: "abc",
});
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 25, status: "running", end_dt: null })]);
const store = useRunStatusStore();
const result = await store.startManualCheck();
expect(result).toEqual({
kind: "started",
runId: 25,
reusedExistingRun: false,
});
expect(store.latestRun?.id).toBe(25);
expect(store.isRunActive).toBe(true);
expect(store.isPolling).toBe(true);
});
it("normalizes run_in_progress responses into already_running state", async () => {
mockedTriggerManualRun.mockRejectedValueOnce(
new ApiRequestError({
status: 409,
code: "run_in_progress",
message: "A run is already in progress for this account.",
details: { run_id: 42 },
requestId: "req_123",
}),
);
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 42, status: "running", end_dt: null })]);
const store = useRunStatusStore();
const result = await store.startManualCheck();
expect(result).toEqual({
kind: "already_running",
runId: 42,
requestId: "req_123",
});
expect(store.latestRun?.id).toBe(42);
expect(store.isRunActive).toBe(true);
expect(store.lastErrorMessage).toBeNull();
});
it("polls while a run is active and stops when it completes", async () => {
vi.useFakeTimers();
mockedListRuns
.mockResolvedValueOnce([buildRun({ id: 90, status: "running", end_dt: null })])
.mockResolvedValueOnce([buildRun({ id: 90, status: "success" })]);
const store = useRunStatusStore();
await store.syncLatest();
expect(store.isPolling).toBe(true);
await vi.advanceTimersByTimeAsync(RUN_STATUS_POLL_INTERVAL_MS + 10);
expect(mockedListRuns).toHaveBeenCalledTimes(2);
expect(store.latestRun?.status).toBe("success");
expect(store.isPolling).toBe(false);
expect(store.isRunActive).toBe(false);
});
it("switches from starting to in-progress when trigger request remains open", async () => {
vi.useFakeTimers();
mockedTriggerManualRun.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
run_id: 77,
status: "running",
scholar_count: 0,
succeeded_count: 0,
failed_count: 0,
partial_count: 0,
new_publication_count: 0,
reused_existing_run: false,
idempotency_key: "x",
});
}, RUN_STATUS_STARTING_PHASE_MS + 500);
}),
);
mockedListRuns.mockResolvedValueOnce([]);
const store = useRunStatusStore();
const startPromise = store.startManualCheck();
expect(store.isSubmitting).toBe(true);
expect(store.isLikelyRunning).toBe(false);
await vi.advanceTimersByTimeAsync(RUN_STATUS_STARTING_PHASE_MS + 20);
expect(store.isLikelyRunning).toBe(true);
await vi.advanceTimersByTimeAsync(520);
await startPromise;
expect(store.isRunActive).toBe(true);
});
});

View file

@ -0,0 +1,264 @@
import { defineStore } from "pinia";
import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors";
export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
export const RUN_STATUS_STARTING_PHASE_MS = 1500;
export type StartManualCheckResult =
| {
kind: "started";
runId: number;
reusedExistingRun: boolean;
}
| {
kind: "already_running";
runId: number | null;
requestId: string | null;
}
| {
kind: "error";
message: string;
requestId: string | null;
};
let pollTimer: ReturnType<typeof setInterval> | null = null;
let syncPromise: Promise<void> | null = null;
let submittingPhaseTimer: ReturnType<typeof setTimeout> | null = null;
function parseRunId(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function extractRunIdFromDetails(details: unknown): number | null {
if (!details || typeof details !== "object") {
return null;
}
const runIdCandidate = (details as Record<string, unknown>).run_id;
return parseRunId(runIdCandidate);
}
function buildPlaceholderRunningRun(runId: number): RunListItem {
return {
id: runId,
trigger_type: "manual",
status: "running",
start_dt: new Date().toISOString(),
end_dt: null,
scholar_count: 0,
new_publication_count: 0,
failed_count: 0,
partial_count: 0,
};
}
export const useRunStatusStore = defineStore("runStatus", {
state: () => ({
latestRun: null as RunListItem | null,
isSubmitting: false,
assumeRunningFromSubmission: false,
isPolling: false,
lastErrorMessage: null as string | null,
lastErrorRequestId: null as string | null,
lastSyncAt: null as number | null,
}),
getters: {
isRunActive(state): boolean {
return state.isSubmitting || state.latestRun?.status === "running";
},
isLikelyRunning(state): boolean {
return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
},
canStart(): boolean {
return !this.isRunActive;
},
},
actions: {
clearSubmittingPhaseTimer(): void {
if (submittingPhaseTimer !== null) {
clearTimeout(submittingPhaseTimer);
submittingPhaseTimer = null;
}
},
beginSubmittingPhaseTracking(): void {
this.assumeRunningFromSubmission = false;
this.clearSubmittingPhaseTimer();
submittingPhaseTimer = setTimeout(() => {
if (!this.isSubmitting) {
return;
}
this.assumeRunningFromSubmission = true;
}, RUN_STATUS_STARTING_PHASE_MS);
},
setLatestRun(run: RunListItem | null): void {
this.latestRun = run;
this.lastSyncAt = Date.now();
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
if (run?.status === "running") {
this.assumeRunningFromSubmission = true;
} else if (!this.isSubmitting) {
this.assumeRunningFromSubmission = false;
}
this.updatePolling();
},
startPolling(): void {
if (pollTimer !== null) {
this.isPolling = true;
return;
}
this.isPolling = true;
pollTimer = setInterval(() => {
void this.syncLatest();
}, RUN_STATUS_POLL_INTERVAL_MS);
},
stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
this.isPolling = false;
},
updatePolling(): void {
if (this.isRunActive) {
this.startPolling();
return;
}
this.stopPolling();
},
async syncLatest(): Promise<void> {
if (syncPromise) {
await syncPromise;
return;
}
syncPromise = (async () => {
try {
const runs = await listRuns({ limit: 1 });
const latest = runs[0] ?? null;
this.latestRun = latest;
this.lastSyncAt = Date.now();
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
if (latest?.status === "running") {
this.assumeRunningFromSubmission = true;
} else if (!this.isSubmitting) {
this.assumeRunningFromSubmission = false;
}
} catch (error) {
if (error instanceof ApiRequestError) {
this.lastErrorMessage = error.message;
this.lastErrorRequestId = error.requestId;
} else {
this.lastErrorMessage = "Unable to refresh check status.";
this.lastErrorRequestId = null;
}
} finally {
syncPromise = null;
this.updatePolling();
}
})();
await syncPromise;
},
async bootstrap(): Promise<void> {
await this.syncLatest();
},
async startManualCheck(): Promise<StartManualCheckResult> {
if (this.isRunActive) {
return {
kind: "already_running",
runId: this.latestRun?.id ?? null,
requestId: null,
};
}
this.isSubmitting = true;
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
this.beginSubmittingPhaseTracking();
this.updatePolling();
try {
const result = await triggerManualRun();
await this.syncLatest();
if (!this.latestRun || this.latestRun.id !== result.run_id) {
this.latestRun = buildPlaceholderRunningRun(result.run_id);
this.lastSyncAt = Date.now();
this.assumeRunningFromSubmission = true;
}
return {
kind: "started",
runId: result.run_id,
reusedExistingRun: result.reused_existing_run,
};
} catch (error) {
if (error instanceof ApiRequestError && error.code === "run_in_progress") {
const runId = extractRunIdFromDetails(error.details);
if (runId !== null) {
this.latestRun = buildPlaceholderRunningRun(runId);
this.lastSyncAt = Date.now();
this.assumeRunningFromSubmission = true;
}
await this.syncLatest();
return {
kind: "already_running",
runId: this.latestRun?.id ?? runId,
requestId: error.requestId,
};
}
if (error instanceof ApiRequestError) {
this.lastErrorMessage = error.message;
this.lastErrorRequestId = error.requestId;
return {
kind: "error",
message: error.message,
requestId: error.requestId,
};
}
const fallbackMessage = "Unable to start an update check.";
this.lastErrorMessage = fallbackMessage;
this.lastErrorRequestId = null;
return {
kind: "error",
message: fallbackMessage,
requestId: null,
};
} finally {
this.isSubmitting = false;
this.clearSubmittingPhaseTimer();
if (this.latestRun?.status !== "running") {
this.assumeRunningFromSubmission = false;
}
this.updatePolling();
}
},
reset(): void {
this.stopPolling();
this.clearSubmittingPhaseTimer();
this.latestRun = null;
this.isSubmitting = false;
this.assumeRunningFromSubmission = false;
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
this.lastSyncAt = null;
},
},
});

View file

@ -239,3 +239,131 @@ async def test_read_state_is_isolated_across_users(db_session: AsyncSession) ->
rows = result.all() rows = result.all()
assert rows == [(user_a, False), (user_b, True)] assert rows == [(user_a, False), (user_b, True)]
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.schema
@pytest.mark.asyncio
async def test_publication_records_are_shared_across_accounts(db_session: AsyncSession) -> None:
user_a = await _insert_user(db_session, "shared-a@example.com")
user_b = await _insert_user(db_session, "shared-b@example.com")
scholar_a = await db_session.execute(
text(
"""
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
VALUES (:user_id, :scholar_id, :display_name)
RETURNING id
"""
),
{"user_id": user_a, "scholar_id": "sharedAAA1111", "display_name": "Shared A"},
)
scholar_a_id = int(scholar_a.scalar_one())
scholar_b = await db_session.execute(
text(
"""
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
VALUES (:user_id, :scholar_id, :display_name)
RETURNING id
"""
),
{"user_id": user_b, "scholar_id": "sharedBBB2222", "display_name": "Shared B"},
)
scholar_b_id = int(scholar_b.scalar_one())
publication = await db_session.execute(
text(
"""
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year)
VALUES (:fingerprint_sha256, :title_raw, :title_normalized, :year)
RETURNING id
"""
),
{
"fingerprint_sha256": "a" * 64,
"title_raw": "Shared Record",
"title_normalized": "shared record",
"year": 2025,
},
)
publication_id = int(publication.scalar_one())
run_a = await db_session.execute(
text(
"""
INSERT INTO crawl_runs (user_id, trigger_type, status)
VALUES (:user_id, :trigger_type, :status)
RETURNING id
"""
),
{"user_id": user_a, "trigger_type": "manual", "status": "success"},
)
run_a_id = int(run_a.scalar_one())
run_b = await db_session.execute(
text(
"""
INSERT INTO crawl_runs (user_id, trigger_type, status)
VALUES (:user_id, :trigger_type, :status)
RETURNING id
"""
),
{"user_id": user_b, "trigger_type": "manual", "status": "success"},
)
run_b_id = int(run_b.scalar_one())
await db_session.execute(
text(
"""
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, first_seen_run_id)
VALUES (:scholar_profile_id, :publication_id, :is_read, :first_seen_run_id)
"""
),
{
"scholar_profile_id": scholar_a_id,
"publication_id": publication_id,
"is_read": False,
"first_seen_run_id": run_a_id,
},
)
await db_session.execute(
text(
"""
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, first_seen_run_id)
VALUES (:scholar_profile_id, :publication_id, :is_read, :first_seen_run_id)
"""
),
{
"scholar_profile_id": scholar_b_id,
"publication_id": publication_id,
"is_read": False,
"first_seen_run_id": run_b_id,
},
)
await db_session.commit()
publication_row_count = await db_session.execute(
text("SELECT COUNT(*) FROM publications WHERE id = :publication_id"),
{"publication_id": publication_id},
)
assert int(publication_row_count.scalar_one()) == 1
link_count = await db_session.execute(
text("SELECT COUNT(*) FROM scholar_publications WHERE publication_id = :publication_id"),
{"publication_id": publication_id},
)
assert int(link_count.scalar_one()) == 2
owner_count = await db_session.execute(
text(
"""
SELECT COUNT(DISTINCT sp.user_id)
FROM scholar_publications spp
JOIN scholar_profiles sp ON sp.id = spp.scholar_profile_id
WHERE spp.publication_id = :publication_id
"""
),
{"publication_id": publication_id},
)
assert int(owner_count.scalar_one()) == 2