test
This commit is contained in:
parent
efd21a7297
commit
441676be27
97 changed files with 10764 additions and 223 deletions
49
frontend/src/features/admin_users/index.ts
Normal file
49
frontend/src/features/admin_users/index.ts
Normal 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;
|
||||
}
|
||||
9
frontend/src/features/auth/index.ts
Normal file
9
frontend/src/features/auth/index.ts
Normal 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";
|
||||
58
frontend/src/features/dashboard/index.ts
Normal file
58
frontend/src/features/dashboard/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
81
frontend/src/features/publications/index.ts
Normal file
81
frontend/src/features/publications/index.ts
Normal 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;
|
||||
}
|
||||
171
frontend/src/features/runs/index.ts
Normal file
171
frontend/src/features/runs/index.ts
Normal 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;
|
||||
}
|
||||
117
frontend/src/features/scholars/index.ts
Normal file
117
frontend/src/features/scholars/index.ts
Normal 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;
|
||||
}
|
||||
34
frontend/src/features/settings/index.ts
Normal file
34
frontend/src/features/settings/index.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue