Skip to content

Commit eed7a73

Browse files
authored
Consolidate admin page data loading into single /api/admin/overview (#381)
1 parent ec96705 commit eed7a73

4 files changed

Lines changed: 212 additions & 327 deletions

File tree

backend/src/main.rs

Lines changed: 96 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,8 @@ async fn main() -> anyhow::Result<()> {
4444
// REST endpoints
4545
.route("/api/cohorts", get(list_cohorts))
4646
// Admin REST endpoints
47-
.route(
48-
"/api/admin/cohorts",
49-
get(admin_list_cohorts).post(create_cohort),
50-
)
47+
.route("/api/admin/overview", get(get_admin_overview))
48+
.route("/api/admin/cohorts", post(create_cohort))
5149
.route("/api/admin/cohorts/:name", put(update_cohort))
5250
.route(
5351
"/api/admin/cohorts/:name/members",
@@ -57,16 +55,13 @@ async fn main() -> anyhow::Result<()> {
5755
"/api/admin/cohorts/:name/members/:id",
5856
put(update_member).delete(remove_member),
5957
)
60-
.route("/api/admin/config", get(get_config).put(update_config))
61-
.route("/api/admin/users", get(list_users))
62-
.route("/api/admin/users/details", get(list_users_detailed))
58+
.route("/api/admin/config", put(update_config))
6359
.route("/api/admin/users/:id/admin", put(toggle_admin))
6460
.route(
6561
"/api/admin/users/:id/display-name",
6662
put(admin_update_display_name),
6763
)
6864
.route("/api/admin/users/:id", delete(delete_user_endpoint))
69-
.route("/api/admin/available-dbs", get(list_available_dbs))
7065
// Authenticated user endpoints
7166
.route("/api/users/me/display-name", put(update_my_display_name))
7267
// Utility routes
@@ -258,18 +253,106 @@ async fn check_admin(state: &AppState, claims: &AccessClaims) -> Result<(), (Sta
258253
}
259254
}
260255

256+
#[derive(Serialize)]
257+
struct AdminOverview {
258+
cohorts: Vec<CohortInfo>,
259+
config: GlobalConfig,
260+
available_dbs: Vec<String>,
261+
users: Vec<UserWithCohorts>,
262+
}
263+
261264
#[axum::debug_handler]
262-
async fn admin_list_cohorts(
265+
async fn get_admin_overview(
263266
claims: AccessClaims,
264267
State(state): State<AppState>,
265-
) -> Result<Json<Vec<CohortInfo>>, (StatusCode, String)> {
268+
) -> Result<Json<AdminOverview>, (StatusCode, String)> {
266269
check_admin(&state, &claims).await?;
267-
state
270+
271+
let cohorts = state
268272
.global_db
269273
.get_all_cohorts()
270274
.await
271-
.map(Json)
272-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
275+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
276+
277+
let active_auction_cohort_id = state
278+
.global_db
279+
.get_config("active_auction_cohort_id")
280+
.await
281+
.unwrap_or(None)
282+
.and_then(|v| v.parse().ok());
283+
let default_cohort_id = state
284+
.global_db
285+
.get_config("default_cohort_id")
286+
.await
287+
.unwrap_or(None)
288+
.and_then(|v| v.parse().ok());
289+
let public_auction_enabled = state
290+
.global_db
291+
.get_config("public_auction_enabled")
292+
.await
293+
.unwrap_or(None)
294+
.is_some_and(|v| v == "true");
295+
let config = GlobalConfig {
296+
active_auction_cohort_id,
297+
default_cohort_id,
298+
public_auction_enabled,
299+
};
300+
301+
let data_dir = std::env::var("DATABASE_URL")
302+
.ok()
303+
.and_then(|url| {
304+
let path = url.trim_start_matches("sqlite://");
305+
Path::new(path)
306+
.parent()
307+
.map(|p| p.to_string_lossy().into_owned())
308+
})
309+
.unwrap_or_else(|| "/data".to_string());
310+
let used: std::collections::HashSet<String> =
311+
cohorts.iter().map(|c| c.db_path.clone()).collect();
312+
let mut available_dbs = Vec::new();
313+
if let Ok(entries) = std::fs::read_dir(&data_dir) {
314+
for entry in entries.flatten() {
315+
let path = entry.path();
316+
if path.extension().is_some_and(|ext| ext == "sqlite") {
317+
let full_path = path.to_string_lossy().to_string();
318+
if !used.contains(&full_path) {
319+
if let Some(stem) = path.file_stem() {
320+
available_dbs.push(stem.to_string_lossy().to_string());
321+
}
322+
}
323+
}
324+
}
325+
}
326+
available_dbs.sort();
327+
328+
let users_raw = state
329+
.global_db
330+
.get_all_users()
331+
.await
332+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
333+
let mut users = Vec::with_capacity(users_raw.len());
334+
for user in users_raw {
335+
let user_cohort_infos = state
336+
.global_db
337+
.get_user_cohorts(user.id)
338+
.await
339+
.unwrap_or_default();
340+
let cohorts = user_cohort_infos
341+
.into_iter()
342+
.map(|ci| UserCohortDetail {
343+
cohort_name: ci.name,
344+
cohort_display_name: ci.display_name,
345+
})
346+
.collect();
347+
users.push(UserWithCohorts { user, cohorts });
348+
}
349+
350+
Ok(Json(AdminOverview {
351+
cohorts,
352+
config,
353+
available_dbs,
354+
users,
355+
}))
273356
}
274357

275358
#[derive(Deserialize)]
@@ -509,41 +592,6 @@ struct GlobalConfig {
509592
public_auction_enabled: bool,
510593
}
511594

512-
#[axum::debug_handler]
513-
async fn get_config(
514-
claims: AccessClaims,
515-
State(state): State<AppState>,
516-
) -> Result<Json<GlobalConfig>, (StatusCode, String)> {
517-
check_admin(&state, &claims).await?;
518-
519-
let active_auction_cohort_id = state
520-
.global_db
521-
.get_config("active_auction_cohort_id")
522-
.await
523-
.unwrap_or(None)
524-
.and_then(|v| v.parse().ok());
525-
526-
let default_cohort_id = state
527-
.global_db
528-
.get_config("default_cohort_id")
529-
.await
530-
.unwrap_or(None)
531-
.and_then(|v| v.parse().ok());
532-
533-
let public_auction_enabled = state
534-
.global_db
535-
.get_config("public_auction_enabled")
536-
.await
537-
.unwrap_or(None)
538-
.is_some_and(|v| v == "true");
539-
540-
Ok(Json(GlobalConfig {
541-
active_auction_cohort_id,
542-
default_cohort_id,
543-
public_auction_enabled,
544-
}))
545-
}
546-
547595
#[derive(Deserialize)]
548596
#[allow(clippy::option_option)] // Intentional: distinguishes "not provided" from "set to null"
549597
struct UpdateConfigRequest {
@@ -593,26 +641,10 @@ async fn update_config(
593641
Ok(StatusCode::OK)
594642
}
595643

596-
#[axum::debug_handler]
597-
async fn list_users(
598-
claims: AccessClaims,
599-
State(state): State<AppState>,
600-
) -> Result<Json<Vec<backend::global_db::GlobalUser>>, (StatusCode, String)> {
601-
check_admin(&state, &claims).await?;
602-
603-
state
604-
.global_db
605-
.get_all_users()
606-
.await
607-
.map(Json)
608-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
609-
}
610-
611644
#[derive(Serialize)]
612645
struct UserCohortDetail {
613646
cohort_name: String,
614647
cohort_display_name: String,
615-
balance: Option<f64>,
616648
}
617649

618650
#[derive(Serialize)]
@@ -622,51 +654,6 @@ struct UserWithCohorts {
622654
cohorts: Vec<UserCohortDetail>,
623655
}
624656

625-
#[axum::debug_handler]
626-
async fn list_users_detailed(
627-
claims: AccessClaims,
628-
State(state): State<AppState>,
629-
) -> Result<Json<Vec<UserWithCohorts>>, (StatusCode, String)> {
630-
check_admin(&state, &claims).await?;
631-
632-
let users = state
633-
.global_db
634-
.get_all_users()
635-
.await
636-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
637-
638-
let mut result = Vec::with_capacity(users.len());
639-
for user in users {
640-
let user_cohort_infos = state
641-
.global_db
642-
.get_user_cohorts(user.id)
643-
.await
644-
.unwrap_or_default();
645-
646-
let mut cohorts = Vec::new();
647-
for ci in &user_cohort_infos {
648-
let balance = if let Some(cs) = state.cohorts.get(&ci.name) {
649-
cs.db
650-
.get_balance_by_global_user_id(user.id)
651-
.await
652-
.ok()
653-
.flatten()
654-
} else {
655-
None
656-
};
657-
cohorts.push(UserCohortDetail {
658-
cohort_name: ci.name.clone(),
659-
cohort_display_name: ci.display_name.clone(),
660-
balance,
661-
});
662-
}
663-
664-
result.push(UserWithCohorts { user, cohorts });
665-
}
666-
667-
Ok(Json(result))
668-
}
669-
670657
#[derive(Deserialize)]
671658
struct ToggleAdminRequest {
672659
is_admin: bool,
@@ -798,52 +785,6 @@ async fn update_member(
798785
Ok(StatusCode::OK)
799786
}
800787

801-
#[axum::debug_handler]
802-
async fn list_available_dbs(
803-
claims: AccessClaims,
804-
State(state): State<AppState>,
805-
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
806-
check_admin(&state, &claims).await?;
807-
808-
let data_dir = std::env::var("DATABASE_URL")
809-
.ok()
810-
.and_then(|url| {
811-
let path = url.trim_start_matches("sqlite://");
812-
Path::new(path)
813-
.parent()
814-
.map(|p| p.to_string_lossy().into_owned())
815-
})
816-
.unwrap_or_else(|| "/data".to_string());
817-
818-
// Collect db_paths already used by cohorts
819-
let cohorts = state
820-
.global_db
821-
.get_all_cohorts()
822-
.await
823-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
824-
let used: std::collections::HashSet<String> = cohorts.into_iter().map(|c| c.db_path).collect();
825-
826-
let mut available = Vec::new();
827-
let Ok(entries) = std::fs::read_dir(&data_dir) else {
828-
return Ok(Json(available));
829-
};
830-
831-
for entry in entries.flatten() {
832-
let path = entry.path();
833-
if path.extension().is_some_and(|ext| ext == "sqlite") {
834-
let full_path = path.to_string_lossy().to_string();
835-
if !used.contains(&full_path) {
836-
if let Some(stem) = path.file_stem() {
837-
available.push(stem.to_string_lossy().to_string());
838-
}
839-
}
840-
}
841-
}
842-
843-
available.sort();
844-
Ok(Json(available))
845-
}
846-
847788
// --- Utility Endpoints ---
848789

849790
#[axum::debug_handler]

frontend/src/lib/adminApi.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export interface GlobalUser {
3636
export interface UserCohortDetail {
3737
cohort_name: string;
3838
cohort_display_name: string;
39-
balance: number | null;
4039
}
4140

4241
export interface UserWithCohorts extends GlobalUser {
@@ -59,8 +58,15 @@ async function handleResponse<T>(res: Response): Promise<T> {
5958
return res.json();
6059
}
6160

62-
export async function fetchAllCohorts(): Promise<CohortInfo[]> {
63-
const res = await fetch(`${API_BASE}/api/admin/cohorts`, { headers: await authHeaders() });
61+
export interface AdminOverview {
62+
cohorts: CohortInfo[];
63+
config: GlobalConfig;
64+
available_dbs: string[];
65+
users: UserWithCohorts[];
66+
}
67+
68+
export async function fetchAdminOverview(): Promise<AdminOverview> {
69+
const res = await fetch(`${API_BASE}/api/admin/overview`, { headers: await authHeaders() });
6470
return handleResponse(res);
6571
}
6672

@@ -100,16 +106,6 @@ export async function fetchMembers(cohortName: string): Promise<CohortMember[]>
100106
return handleResponse(res);
101107
}
102108

103-
export async function fetchGlobalUsers(): Promise<GlobalUser[]> {
104-
const res = await fetch(`${API_BASE}/api/admin/users`, { headers: await authHeaders() });
105-
return handleResponse(res);
106-
}
107-
108-
export async function fetchUsersDetailed(): Promise<UserWithCohorts[]> {
109-
const res = await fetch(`${API_BASE}/api/admin/users/details`, { headers: await authHeaders() });
110-
return handleResponse(res);
111-
}
112-
113109
export async function batchAddMembers(
114110
cohortName: string,
115111
opts: { emails?: string[]; user_ids?: number[]; initial_balance?: string }
@@ -133,20 +129,6 @@ export async function removeMember(cohortName: string, memberId: number): Promis
133129
}
134130
}
135131

136-
export async function fetchConfig(): Promise<GlobalConfig> {
137-
const res = await fetch(`${API_BASE}/api/admin/config`, { headers: await authHeaders() });
138-
return handleResponse(res);
139-
}
140-
141-
export async function checkAdminAccess(): Promise<boolean> {
142-
try {
143-
const res = await fetch(`${API_BASE}/api/admin/config`, { headers: await authHeaders() });
144-
return res.ok;
145-
} catch {
146-
return false;
147-
}
148-
}
149-
150132
export async function updateConfig(config: {
151133
active_auction_cohort_id?: number | null;
152134
default_cohort_id?: number | null;
@@ -163,11 +145,6 @@ export async function updateConfig(config: {
163145
}
164146
}
165147

166-
export async function fetchAvailableDbs(): Promise<string[]> {
167-
const res = await fetch(`${API_BASE}/api/admin/available-dbs`, { headers: await authHeaders() });
168-
return handleResponse(res);
169-
}
170-
171148
export async function toggleAdmin(userId: number, isAdmin: boolean): Promise<void> {
172149
const res = await fetch(`${API_BASE}/api/admin/users/${userId}/admin`, {
173150
method: 'PUT',

0 commit comments

Comments
 (0)