//! Admin API handlers. //! //! All endpoints require the `AdminUser` extractor, returning 401/403 as needed. use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Json, Response}; use chrono::{DateTime, Utc}; use serde::Deserialize; use serde_json::{Value, json}; use crate::data::models::User; use crate::state::AppState; use crate::web::extractors::AdminUser; /// `GET /api/admin/status` — Enhanced system status for admins. pub async fn admin_status( AdminUser(_user): AdminUser, State(state): State, ) -> Result, (StatusCode, Json)> { let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, "failed to count users"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to count users"})), ) })?; let (session_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_sessions WHERE expires_at > now()") .fetch_one(&state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, "failed to count sessions"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to count sessions"})), ) })?; let course_count = state.get_course_count().await.map_err(|e| { tracing::error!(error = %e, "failed to count courses"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to count courses"})), ) })?; let (scrape_job_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scrape_jobs") .fetch_one(&state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, "failed to count scrape jobs"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to count scrape jobs"})), ) })?; let services: Vec = state .service_statuses .all() .into_iter() .map(|(name, status)| { json!({ "name": name, "status": status, }) }) .collect(); Ok(Json(json!({ "userCount": user_count, "sessionCount": session_count, "courseCount": course_count, "scrapeJobCount": scrape_job_count, "services": services, }))) } /// `GET /api/admin/users` — List all users. pub async fn list_users( AdminUser(_user): AdminUser, State(state): State, ) -> Result>, (StatusCode, Json)> { let users = crate::data::users::list_users(&state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, "failed to list users"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to list users"})), ) })?; Ok(Json(users)) } #[derive(Deserialize)] pub struct SetAdminBody { is_admin: bool, } /// `PUT /api/admin/users/{discord_id}/admin` — Set admin status for a user. pub async fn set_user_admin( AdminUser(_user): AdminUser, State(state): State, Path(discord_id): Path, Json(body): Json, ) -> Result, (StatusCode, Json)> { let user = crate::data::users::set_admin(&state.db_pool, discord_id, body.is_admin) .await .map_err(|e| { tracing::error!(error = %e, "failed to set admin status"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to set admin status"})), ) })? .ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(json!({"error": "user not found"})), ) })?; state.session_cache.evict_user(discord_id); Ok(Json(user)) } /// `GET /api/admin/scrape-jobs` — List scrape jobs. pub async fn list_scrape_jobs( AdminUser(_user): AdminUser, State(state): State, ) -> Result, (StatusCode, Json)> { let rows = sqlx::query_as::<_, crate::data::models::ScrapeJob>( "SELECT * FROM scrape_jobs ORDER BY priority DESC, execute_at ASC LIMIT 100", ) .fetch_all(&state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, "failed to list scrape jobs"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to list scrape jobs"})), ) })?; let jobs: Vec = rows .iter() .map(|j| { json!({ "id": j.id, "targetType": format!("{:?}", j.target_type), "targetPayload": j.target_payload, "priority": format!("{:?}", j.priority), "executeAt": j.execute_at.to_rfc3339(), "createdAt": j.created_at.to_rfc3339(), "lockedAt": j.locked_at.map(|t| t.to_rfc3339()), "retryCount": j.retry_count, "maxRetries": j.max_retries, "queuedAt": j.queued_at.to_rfc3339(), "status": j.status(), }) }) .collect(); Ok(Json(json!({ "jobs": jobs }))) } /// Row returned by the audit-log query (audit + joined course fields). #[derive(sqlx::FromRow, Debug)] struct AuditRow { id: i32, course_id: i32, timestamp: chrono::DateTime, field_changed: String, old_value: String, new_value: String, // Joined from courses table (nullable in case the course was deleted) subject: Option, course_number: Option, crn: Option, title: Option, } /// Format a `DateTime` as an HTTP-date (RFC 2822) for Last-Modified headers. fn to_http_date(dt: &DateTime) -> String { dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string() } /// Parse an `If-Modified-Since` header value into a `DateTime`. fn parse_if_modified_since(headers: &HeaderMap) -> Option> { let val = headers.get(header::IF_MODIFIED_SINCE)?.to_str().ok()?; DateTime::parse_from_rfc2822(val) .ok() .map(|dt| dt.with_timezone(&Utc)) } /// `GET /api/admin/audit-log` — List recent audit entries. /// /// Supports `If-Modified-Since`: returns 304 when the newest entry hasn't changed. pub async fn list_audit_log( AdminUser(_user): AdminUser, headers: HeaderMap, State(state): State, ) -> Result)> { let rows = sqlx::query_as::<_, AuditRow>( "SELECT a.id, a.course_id, a.timestamp, a.field_changed, a.old_value, a.new_value, \ c.subject, c.course_number, c.crn, c.title \ FROM course_audits a \ LEFT JOIN courses c ON c.id = a.course_id \ ORDER BY a.timestamp DESC LIMIT 200", ) .fetch_all(&state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, "failed to list audit log"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to list audit log"})), ) })?; // Determine the latest timestamp across all rows (query is DESC so first row is newest) let latest = rows.first().map(|r| r.timestamp); // If the client sent If-Modified-Since and our data hasn't changed, return 304 if let (Some(since), Some(latest_ts)) = (parse_if_modified_since(&headers), latest) { // Truncate to seconds for comparison (HTTP dates have second precision) if latest_ts.timestamp() <= since.timestamp() { let mut resp = StatusCode::NOT_MODIFIED.into_response(); if let Ok(val) = to_http_date(&latest_ts).parse() { resp.headers_mut().insert(header::LAST_MODIFIED, val); } return Ok(resp); } } let entries: Vec = rows .iter() .map(|a| { json!({ "id": a.id, "courseId": a.course_id, "timestamp": a.timestamp.to_rfc3339(), "fieldChanged": a.field_changed, "oldValue": a.old_value, "newValue": a.new_value, "subject": a.subject, "courseNumber": a.course_number, "crn": a.crn, "courseTitle": a.title, }) }) .collect(); let mut resp = Json(json!({ "entries": entries })).into_response(); if let Some(latest_ts) = latest && let Ok(val) = to_http_date(&latest_ts).parse() { resp.headers_mut().insert(header::LAST_MODIFIED, val); } Ok(resp) }