This commit is contained in:
Justin Visser 2026-02-17 20:24:12 +01:00
parent efd21a7297
commit 441676be27
97 changed files with 10764 additions and 223 deletions

View file

@ -0,0 +1,49 @@
import { apiRequest } from "@/lib/api/client";
export interface AdminUser {
id: number;
email: string;
is_active: boolean;
is_admin: boolean;
created_at: string;
updated_at: string;
}
interface UsersListData {
users: AdminUser[];
}
export interface CreateAdminUserPayload {
email: string;
password: string;
is_admin: boolean;
}
export async function listAdminUsers(): Promise<AdminUser[]> {
const response = await apiRequest<UsersListData>("/admin/users", { method: "GET" });
return response.data.users;
}
export async function createAdminUser(payload: CreateAdminUserPayload): Promise<AdminUser> {
const response = await apiRequest<AdminUser>("/admin/users", {
method: "POST",
body: payload,
});
return response.data;
}
export async function setAdminUserActive(userId: number, isActive: boolean): Promise<AdminUser> {
const response = await apiRequest<AdminUser>(`/admin/users/${userId}/active`, {
method: "PATCH",
body: { is_active: isActive },
});
return response.data;
}
export async function resetAdminUserPassword(userId: number, newPassword: string): Promise<{ message: string }> {
const response = await apiRequest<{ message: string }>(`/admin/users/${userId}/reset-password`, {
method: "POST",
body: { new_password: newPassword },
});
return response.data;
}

View file

@ -0,0 +1,9 @@
export { fetchCsrfBootstrap, type CsrfBootstrapData } from "@/lib/api/csrf";
export {
fetchMe,
loginSession,
logoutSession,
type AuthSessionData,
type MessageData,
type SessionUser,
} from "@/lib/auth/session";

View file

@ -0,0 +1,58 @@
import {
listPublications,
type PublicationItem,
type PublicationMode,
} from "@/features/publications";
import { listQueueItems, listRuns, type RunListItem } from "@/features/runs";
export interface QueueHealth {
queued: number;
retrying: number;
dropped: number;
}
export interface DashboardSnapshot {
newCount: number;
totalCount: number;
mode: PublicationMode;
latestRun: RunListItem | null;
recentRuns: RunListItem[];
recentPublications: PublicationItem[];
queue: QueueHealth;
}
function countQueueStatuses(statuses: string[]): QueueHealth {
return statuses.reduce<QueueHealth>(
(acc, status) => {
if (status === "queued") {
acc.queued += 1;
} else if (status === "retrying") {
acc.retrying += 1;
} else if (status === "dropped") {
acc.dropped += 1;
}
return acc;
},
{ queued: 0, retrying: 0, dropped: 0 },
);
}
export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
const [publications, runs, queueItems] = await Promise.all([
listPublications({ mode: "new", limit: 20 }),
listRuns({ limit: 5 }),
listQueueItems(200),
]);
const queueHealth = countQueueStatuses(queueItems.map((item) => item.status));
return {
newCount: publications.new_count,
totalCount: publications.total_count,
mode: publications.mode,
latestRun: runs[0] ?? null,
recentRuns: runs,
recentPublications: publications.publications,
queue: queueHealth,
};
}

View file

@ -0,0 +1,81 @@
import { apiRequest } from "@/lib/api/client";
export type PublicationMode = "all" | "new";
export interface PublicationItem {
publication_id: number;
scholar_profile_id: number;
scholar_label: string;
title: string;
year: number | null;
citation_count: number;
venue_text: string | null;
pub_url: string | null;
is_read: boolean;
first_seen_at: string;
is_new_in_latest_run: boolean;
}
export interface PublicationsResult {
mode: PublicationMode;
selected_scholar_profile_id: number | null;
new_count: number;
total_count: number;
publications: PublicationItem[];
}
export interface PublicationsQuery {
mode?: PublicationMode;
scholarProfileId?: number;
limit?: number;
}
export interface PublicationSelection {
scholar_profile_id: number;
publication_id: number;
}
export async function listPublications(query: PublicationsQuery = {}): Promise<PublicationsResult> {
const params = new URLSearchParams();
if (query.mode) {
params.set("mode", query.mode);
}
if (query.scholarProfileId) {
params.set("scholar_profile_id", String(query.scholarProfileId));
}
if (query.limit) {
params.set("limit", String(query.limit));
}
const suffix = params.toString();
const response = await apiRequest<PublicationsResult>(
`/publications${suffix ? `?${suffix}` : ""}`,
{ method: "GET" },
);
return response.data;
}
export async function markAllRead(): Promise<{ message: string; updated_count: number }> {
const response = await apiRequest<{ message: string; updated_count: number }>(
"/publications/mark-all-read",
{ method: "POST" },
);
return response.data;
}
export async function markSelectedRead(selections: PublicationSelection[]): Promise<{
message: string;
requested_count: number;
updated_count: number;
}> {
const response = await apiRequest<{
message: string;
requested_count: number;
updated_count: number;
}>("/publications/mark-read", {
method: "POST",
body: { selections },
});
return response.data;
}

View file

@ -0,0 +1,171 @@
import { apiRequest } from "@/lib/api/client";
export interface RunListItem {
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;
}
export interface RunSummary {
succeeded_count: number;
failed_count: number;
partial_count: number;
failed_state_counts: Record<string, number>;
failed_reason_counts: Record<string, number>;
}
export interface RunScholarResult {
scholar_profile_id: number;
scholar_id: string;
state: string;
state_reason: string | null;
outcome: string;
attempt_count: number;
publication_count: number;
start_cstart: number;
continuation_cstart: number | null;
continuation_enqueued: boolean;
continuation_cleared: boolean;
warnings: string[];
error: string | null;
debug: Record<string, unknown> | null;
}
export interface RunDetail {
run: RunListItem;
summary: RunSummary;
scholar_results: RunScholarResult[];
}
export interface QueueItem {
id: number;
scholar_profile_id: number;
scholar_label: string;
status: string;
reason: string;
dropped_reason: string | null;
attempt_count: number;
resume_cstart: number;
next_attempt_dt: string | null;
updated_at: string;
last_error: string | null;
last_run_id: number | null;
}
interface RunsListData {
runs: RunListItem[];
}
interface QueueListData {
queue_items: QueueItem[];
}
export interface RunsListQuery {
failedOnly?: boolean;
limit?: number;
}
export async function listRuns(query: RunsListQuery = {}): Promise<RunListItem[]> {
const params = new URLSearchParams();
if (query.failedOnly) {
params.set("failed_only", "true");
}
if (query.limit) {
params.set("limit", String(query.limit));
}
const suffix = params.toString();
const response = await apiRequest<RunsListData>(`/runs${suffix ? `?${suffix}` : ""}`, {
method: "GET",
});
return response.data.runs;
}
export async function getRunDetail(runId: number): Promise<RunDetail> {
const response = await apiRequest<RunDetail>(`/runs/${runId}`, { method: "GET" });
return response.data;
}
function generateIdempotencyKey(): string {
const randomUuid = globalThis.crypto?.randomUUID?.();
if (randomUuid) {
return randomUuid;
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export async function triggerManualRun(): Promise<{
run_id: number;
status: string;
scholar_count: number;
succeeded_count: number;
failed_count: number;
partial_count: number;
new_publication_count: number;
reused_existing_run: boolean;
idempotency_key: string | null;
}> {
const headers: Record<string, string> = {
"Idempotency-Key": generateIdempotencyKey(),
};
const response = await apiRequest<{
run_id: number;
status: string;
scholar_count: number;
succeeded_count: number;
failed_count: number;
partial_count: number;
new_publication_count: number;
reused_existing_run: boolean;
idempotency_key: string | null;
}>("/runs/manual", {
method: "POST",
headers,
});
return response.data;
}
export async function listQueueItems(limit = 200): Promise<QueueItem[]> {
const response = await apiRequest<QueueListData>(`/runs/queue/items?limit=${limit}`, {
method: "GET",
});
return response.data.queue_items;
}
export async function retryQueueItem(queueItemId: number): Promise<QueueItem> {
const response = await apiRequest<QueueItem>(`/runs/queue/${queueItemId}/retry`, {
method: "POST",
});
return response.data;
}
export async function dropQueueItem(queueItemId: number): Promise<QueueItem> {
const response = await apiRequest<QueueItem>(`/runs/queue/${queueItemId}/drop`, {
method: "POST",
});
return response.data;
}
export async function clearQueueItem(queueItemId: number): Promise<{
queue_item_id: number;
previous_status: string;
status: string;
message: string;
}> {
const response = await apiRequest<{
queue_item_id: number;
previous_status: string;
status: string;
message: string;
}>(`/runs/queue/${queueItemId}`, {
method: "DELETE",
});
return response.data;
}

View file

@ -0,0 +1,117 @@
import { apiRequest } from "@/lib/api/client";
export interface ScholarProfile {
id: number;
scholar_id: string;
display_name: string | null;
profile_image_url: string | null;
profile_image_source: "upload" | "override" | "scraped" | "none";
is_enabled: boolean;
baseline_completed: boolean;
last_run_dt: string | null;
last_run_status: string | null;
}
export interface ScholarCreatePayload {
scholar_id: string;
profile_image_url?: string;
}
export interface ScholarSearchCandidate {
scholar_id: string;
display_name: string;
affiliation: string | null;
email_domain: string | null;
cited_by_count: number | null;
interests: string[];
profile_url: string;
profile_image_url: string | null;
}
export interface ScholarSearchResult {
query: string;
state: "ok" | "no_results" | "blocked_or_captcha" | "layout_changed" | "network_error";
state_reason: string;
action_hint: string | null;
candidates: ScholarSearchCandidate[];
warnings: string[];
}
interface ScholarsListData {
scholars: ScholarProfile[];
}
interface ScholarSearchData extends ScholarSearchResult {}
export async function listScholars(): Promise<ScholarProfile[]> {
const response = await apiRequest<ScholarsListData>("/scholars", { method: "GET" });
return response.data.scholars;
}
export async function createScholar(payload: ScholarCreatePayload): Promise<ScholarProfile> {
const response = await apiRequest<ScholarProfile>("/scholars", {
method: "POST",
body: payload,
});
return response.data;
}
export async function toggleScholar(scholarProfileId: number): Promise<ScholarProfile> {
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/toggle`, {
method: "PATCH",
});
return response.data;
}
export async function deleteScholar(scholarProfileId: number): Promise<void> {
await apiRequest<{ message: string }>(`/scholars/${scholarProfileId}`, {
method: "DELETE",
});
}
export async function searchScholarsByName(
query: string,
limit = 10,
): Promise<ScholarSearchResult> {
const searchParams = new URLSearchParams({
query,
limit: String(limit),
});
const response = await apiRequest<ScholarSearchData>(`/scholars/search?${searchParams.toString()}`, {
method: "GET",
});
return response.data;
}
export async function setScholarImageUrl(
scholarProfileId: number,
imageUrl: string,
): Promise<ScholarProfile> {
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/image/url`, {
method: "PUT",
body: { image_url: imageUrl },
});
return response.data;
}
export async function uploadScholarImage(
scholarProfileId: number,
file: File,
): Promise<ScholarProfile> {
const form = new FormData();
form.append("image", file);
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/image/upload`, {
method: "POST",
body: form,
});
return response.data;
}
export async function clearScholarImage(
scholarProfileId: number,
): Promise<ScholarProfile> {
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/image`, {
method: "DELETE",
});
return response.data;
}

View file

@ -0,0 +1,34 @@
import { apiRequest } from "@/lib/api/client";
export interface UserSettings {
auto_run_enabled: boolean;
run_interval_minutes: number;
request_delay_seconds: number;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;
confirm_password: string;
}
export async function fetchSettings(): Promise<UserSettings> {
const response = await apiRequest<UserSettings>("/settings", { method: "GET" });
return response.data;
}
export async function updateSettings(payload: UserSettings): Promise<UserSettings> {
const response = await apiRequest<UserSettings>("/settings", {
method: "PUT",
body: payload,
});
return response.data;
}
export async function changePassword(payload: ChangePasswordPayload): Promise<{ message: string }> {
const response = await apiRequest<{ message: string }>("/auth/change-password", {
method: "POST",
body: payload,
});
return response.data;
}