diff --git a/src/data/courses.rs b/src/data/courses.rs index bf7a5a7..e042110 100644 --- a/src/data/courses.rs +++ b/src/data/courses.rs @@ -4,10 +4,12 @@ use crate::data::models::{Course, CourseInstructorDetail}; use crate::error::Result; use sqlx::PgPool; use std::collections::HashMap; +use ts_rs::TS; /// Column to sort search results by. -#[derive(Debug, Clone, Copy, serde::Deserialize)] +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)] #[serde(rename_all = "snake_case")] +#[ts(export)] pub enum SortColumn { CourseCode, Title, @@ -17,8 +19,9 @@ pub enum SortColumn { } /// Sort direction. -#[derive(Debug, Clone, Copy, serde::Deserialize)] +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)] #[serde(rename_all = "snake_case")] +#[ts(export)] pub enum SortDirection { Asc, Desc, diff --git a/src/web/admin_rmp.rs b/src/web/admin_rmp.rs index 40149cf..9df9744 100644 --- a/src/web/admin_rmp.rs +++ b/src/web/admin_rmp.rs @@ -14,25 +14,34 @@ use crate::web::extractors::AdminUser; // Query / body types // --------------------------------------------------------------------------- -#[derive(Deserialize)] +#[derive(Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] pub struct ListInstructorsParams { - status: Option, - search: Option, - page: Option, - per_page: Option, - sort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub per_page: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] +#[ts(export)] pub struct MatchBody { - rmp_legacy_id: i32, + pub rmp_legacy_id: i32, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] +#[ts(export)] pub struct RejectCandidateBody { - rmp_legacy_id: i32, + pub rmp_legacy_id: i32, } // --------------------------------------------------------------------------- diff --git a/src/web/admin_scraper.rs b/src/web/admin_scraper.rs index 7a598d0..0f6696e 100644 --- a/src/web/admin_scraper.rs +++ b/src/web/admin_scraper.rs @@ -77,10 +77,12 @@ fn default_bucket_for_period(period: &str) -> &'static str { // Endpoint 1: GET /api/admin/scraper/stats // --------------------------------------------------------------------------- -#[derive(Deserialize)] +#[derive(Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] pub struct StatsParams { #[serde(default = "default_period")] - period: String, + pub period: String, } fn default_period() -> String { @@ -195,11 +197,14 @@ pub async fn scraper_stats( // Endpoint 2: GET /api/admin/scraper/timeseries // --------------------------------------------------------------------------- -#[derive(Deserialize)] +#[derive(Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] pub struct TimeseriesParams { #[serde(default = "default_period")] - period: String, - bucket: Option, + pub period: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub bucket: Option, } #[derive(Serialize, TS)] @@ -215,6 +220,8 @@ pub struct TimeseriesResponse { #[ts(export)] #[serde(rename_all = "camelCase")] pub struct TimeseriesPoint { + /// ISO-8601 UTC timestamp for this data point (e.g., "2024-01-15T10:00:00Z") + #[ts(type = "string")] timestamp: DateTime, #[ts(type = "number")] scrape_count: i64, @@ -328,7 +335,11 @@ pub struct SubjectSummary { #[ts(type = "number")] current_interval_secs: u64, time_multiplier: u32, + /// ISO-8601 UTC timestamp of last scrape (e.g., "2024-01-15T10:30:00Z") + #[ts(type = "string")] last_scraped: DateTime, + /// ISO-8601 UTC timestamp when next scrape is eligible (e.g., "2024-01-15T11:00:00Z") + #[ts(type = "string | null")] next_eligible_at: Option>, #[ts(type = "number | null")] cooldown_remaining_secs: Option, @@ -439,10 +450,12 @@ pub async fn scraper_subjects( // Endpoint 4: GET /api/admin/scraper/subjects/{subject} // --------------------------------------------------------------------------- -#[derive(Deserialize)] +#[derive(Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] pub struct SubjectDetailParams { #[serde(default = "default_detail_limit")] - limit: i32, + pub limit: i32, } fn default_detail_limit() -> i32 { @@ -463,6 +476,8 @@ pub struct SubjectDetailResponse { pub struct SubjectResultEntry { #[ts(type = "number")] id: i64, + /// ISO-8601 UTC timestamp when the scrape job completed (e.g., "2024-01-15T10:30:00Z") + #[ts(type = "string")] completed_at: DateTime, duration_ms: i32, success: bool, diff --git a/src/web/error.rs b/src/web/error.rs new file mode 100644 index 0000000..409f027 --- /dev/null +++ b/src/web/error.rs @@ -0,0 +1,90 @@ +//! Standardized API error responses. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; +use ts_rs::TS; + +/// Standardized error response for all API endpoints. +#[derive(Debug, Clone, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ApiError { + /// Machine-readable error code (e.g., "NOT_FOUND", "INVALID_TERM") + pub code: String, + /// Human-readable error message + pub message: String, + /// Optional additional details (validation errors, field info, etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ApiError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + details: None, + } + } + + #[allow(dead_code)] + pub fn with_details(mut self, details: serde_json::Value) -> Self { + self.details = Some(details); + self + } + + pub fn not_found(message: impl Into) -> Self { + Self::new("NOT_FOUND", message) + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new("BAD_REQUEST", message) + } + + pub fn internal_error(message: impl Into) -> Self { + Self::new("INTERNAL_ERROR", message) + } + + pub fn invalid_term(term: impl std::fmt::Display) -> Self { + Self::new("INVALID_TERM", format!("Invalid term: {}", term)) + } + + fn status_code(&self) -> StatusCode { + match self.code.as_str() { + "NOT_FOUND" => StatusCode::NOT_FOUND, + "BAD_REQUEST" | "INVALID_TERM" | "INVALID_RANGE" => StatusCode::BAD_REQUEST, + "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, + "FORBIDDEN" => StatusCode::FORBIDDEN, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = self.status_code(); + (status, Json(self)).into_response() + } +} + +/// Convert `(StatusCode, String)` tuple errors to ApiError +impl From<(StatusCode, String)> for ApiError { + fn from((status, message): (StatusCode, String)) -> Self { + let code = match status { + StatusCode::NOT_FOUND => "NOT_FOUND", + StatusCode::BAD_REQUEST => "BAD_REQUEST", + StatusCode::UNAUTHORIZED => "UNAUTHORIZED", + StatusCode::FORBIDDEN => "FORBIDDEN", + _ => "INTERNAL_ERROR", + }; + Self::new(code, message) + } +} + +/// Helper for converting database errors to ApiError +pub fn db_error(context: &str, error: anyhow::Error) -> ApiError { + tracing::error!(error = %error, context = context, "Database error"); + ApiError::internal_error(format!("{} failed", context)) +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 6d06916..5deae02 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -9,6 +9,7 @@ pub mod auth; pub mod calendar; #[cfg(feature = "embed-assets")] pub mod encoding; +pub mod error; pub mod extractors; pub mod routes; pub mod schedule_cache; diff --git a/src/web/routes.rs b/src/web/routes.rs index b25478b..f2905ca 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -4,7 +4,6 @@ use axum::{ Extension, Router, body::Body, extract::{Path, Query, Request, State}, - http::StatusCode as AxumStatusCode, response::{Json, Response}, routing::{get, post, put}, }; @@ -12,6 +11,7 @@ use axum::{ use crate::web::admin_scraper; use crate::web::auth::{self, AuthConfig}; use crate::web::calendar; +use crate::web::error::{ApiError, db_error}; use crate::web::timeline; use crate::web::ws; use crate::{data, web::admin}; @@ -291,7 +291,7 @@ async fn status(State(state): State) -> Json { async fn metrics( State(state): State, Query(params): Query, -) -> Result, (AxumStatusCode, String)> { +) -> Result, ApiError> { let limit = params.limit.clamp(1, 5000); // Parse range shorthand, defaulting to 24h @@ -303,8 +303,8 @@ async fn metrics( "7d" => chrono::Duration::days(7), "30d" => chrono::Duration::days(30), _ => { - return Err(( - AxumStatusCode::BAD_REQUEST, + return Err(ApiError::new( + "INVALID_RANGE", format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"), )); } @@ -321,13 +321,7 @@ async fn metrics( .bind(crn) .fetch_optional(&state.db_pool) .await - .map_err(|e| { - tracing::error!(error = %e, "Course lookup for metrics failed"); - ( - AxumStatusCode::INTERNAL_SERVER_ERROR, - "Course lookup failed".to_string(), - ) - })?; + .map_err(|e| db_error("Course lookup for metrics", e.into()))?; row.map(|(id,)| id) } else { None @@ -361,13 +355,7 @@ async fn metrics( .fetch_all(&state.db_pool) .await } - .map_err(|e| { - tracing::error!(error = %e, "Metrics query failed"); - ( - AxumStatusCode::INTERNAL_SERVER_ERROR, - "Metrics query failed".to_string(), - ) - })?; + .map_err(|e| db_error("Metrics query", e.into()))?; let count = metrics.len(); let metrics_entries: Vec = metrics @@ -416,44 +404,60 @@ pub struct MetricsResponse { pub timestamp: String, } -#[derive(Deserialize)] -struct MetricsParams { - course_id: Option, - term: Option, - crn: Option, - /// Shorthand durations: "1h", "6h", "24h", "7d", "30d" - range: Option, +#[derive(Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MetricsParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub course_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub term: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub crn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, #[serde(default = "default_metrics_limit")] - limit: i32, + pub limit: i32, } fn default_metrics_limit() -> i32 { 500 } -#[derive(Deserialize)] -struct SubjectsParams { - term: String, +#[derive(Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SubjectsParams { + pub term: String, } -#[derive(Deserialize)] -struct SearchParams { - term: String, +#[derive(Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SearchParams { + pub term: String, #[serde(default)] - subject: Vec, - q: Option, - course_number_low: Option, - course_number_high: Option, + pub subject: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub q: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub course_number_low: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub course_number_high: Option, #[serde(default)] - open_only: bool, - instructional_method: Option, - campus: Option, + pub open_only: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructional_method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub campus: Option, #[serde(default = "default_limit")] - limit: i32, + pub limit: i32, #[serde(default)] - offset: i32, - sort_by: Option, - sort_dir: Option, + pub offset: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_dir: Option, } use crate::data::courses::{SortColumn, SortDirection}; @@ -550,6 +554,32 @@ fn build_course_response( }) .collect(); + let meeting_times = serde_json::from_value(course.meeting_times.clone()) + .map_err(|e| { + tracing::error!( + course_id = course.id, + crn = %course.crn, + term = %course.term_code, + error = %e, + "Failed to deserialize meeting_times JSONB" + ); + e + }) + .unwrap_or_default(); + + let attributes = serde_json::from_value(course.attributes.clone()) + .map_err(|e| { + tracing::error!( + course_id = course.id, + crn = %course.crn, + term = %course.term_code, + error = %e, + "Failed to deserialize attributes JSONB" + ); + e + }) + .unwrap_or_default(); + CourseResponse { crn: course.crn.clone(), subject: course.subject.clone(), @@ -572,8 +602,8 @@ fn build_course_response( link_identifier: course.link_identifier.clone(), is_section_linked: course.is_section_linked, part_of_term: course.part_of_term.clone(), - meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(), - attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(), + meeting_times, + attributes, instructors, } } @@ -582,15 +612,11 @@ fn build_course_response( async fn search_courses( State(state): State, axum_extra::extract::Query(params): axum_extra::extract::Query, -) -> Result, (AxumStatusCode, String)> { +) -> Result, ApiError> { use crate::banner::models::terms::Term; - let term_code = Term::resolve_to_code(¶ms.term).ok_or_else(|| { - ( - AxumStatusCode::BAD_REQUEST, - format!("Invalid term: {}", params.term), - ) - })?; + let term_code = + Term::resolve_to_code(¶ms.term).ok_or_else(|| ApiError::invalid_term(¶ms.term))?; let limit = params.limit.clamp(1, 100); let offset = params.offset.max(0); @@ -614,13 +640,7 @@ async fn search_courses( params.sort_dir, ) .await - .map_err(|e| { - tracing::error!(error = %e, "Course search failed"); - ( - AxumStatusCode::INTERNAL_SERVER_ERROR, - "Search failed".to_string(), - ) - })?; + .map_err(|e| db_error("Course search", e))?; // Batch-fetch all instructors in a single query instead of N+1 let course_ids: Vec = courses.iter().map(|c| c.id).collect(); @@ -647,17 +667,11 @@ async fn search_courses( async fn get_course( State(state): State, Path((term, crn)): Path<(String, String)>, -) -> Result, (AxumStatusCode, String)> { +) -> Result, ApiError> { let course = data::courses::get_course_by_crn(&state.db_pool, &crn, &term) .await - .map_err(|e| { - tracing::error!(error = %e, "Course lookup failed"); - ( - AxumStatusCode::INTERNAL_SERVER_ERROR, - "Lookup failed".to_string(), - ) - })? - .ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?; + .map_err(|e| db_error("Course lookup", e))? + .ok_or_else(|| ApiError::not_found("Course not found"))?; let instructors = data::courses::get_course_instructors(&state.db_pool, course.id) .await @@ -666,20 +680,12 @@ async fn get_course( } /// `GET /api/terms` -async fn get_terms( - State(state): State, -) -> Result>, (AxumStatusCode, String)> { +async fn get_terms(State(state): State) -> Result>, ApiError> { use crate::banner::models::terms::Term; let term_codes = data::courses::get_available_terms(&state.db_pool) .await - .map_err(|e| { - tracing::error!(error = %e, "Failed to get terms"); - ( - AxumStatusCode::INTERNAL_SERVER_ERROR, - "Failed to get terms".to_string(), - ) - })?; + .map_err(|e| db_error("Get terms", e))?; let terms: Vec = term_codes .into_iter() @@ -700,24 +706,14 @@ async fn get_terms( async fn get_subjects( State(state): State, Query(params): Query, -) -> Result>, (AxumStatusCode, String)> { +) -> Result>, ApiError> { use crate::banner::models::terms::Term; - let term_code = Term::resolve_to_code(¶ms.term).ok_or_else(|| { - ( - AxumStatusCode::BAD_REQUEST, - format!("Invalid term: {}", params.term), - ) - })?; + let term_code = + Term::resolve_to_code(¶ms.term).ok_or_else(|| ApiError::invalid_term(¶ms.term))?; let rows = data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code) .await - .map_err(|e| { - tracing::error!(error = %e, "Failed to get subjects"); - ( - AxumStatusCode::INTERNAL_SERVER_ERROR, - "Failed to get subjects".to_string(), - ) - })?; + .map_err(|e| db_error("Get subjects", e))?; let subjects: Vec = rows .into_iter() @@ -731,7 +727,7 @@ async fn get_subjects( async fn get_reference( State(state): State, Path(category): Path, -) -> Result>, (AxumStatusCode, String)> { +) -> Result>, ApiError> { let cache = state.reference_cache.read().await; let entries = cache.entries_for_category(&category); @@ -740,13 +736,7 @@ async fn get_reference( drop(cache); let rows = data::reference::get_by_category(&category, &state.db_pool) .await - .map_err(|e| { - tracing::error!(error = %e, category = %category, "Reference lookup failed"); - ( - AxumStatusCode::INTERNAL_SERVER_ERROR, - "Lookup failed".to_string(), - ) - })?; + .map_err(|e| db_error(&format!("Reference lookup for {}", category), e))?; return Ok(Json( rows.into_iter() diff --git a/src/web/timeline.rs b/src/web/timeline.rs index 1cebb0a..030f3b9 100644 --- a/src/web/timeline.rs +++ b/src/web/timeline.rs @@ -9,11 +9,7 @@ //! [`ScheduleCache`]) that refreshes hourly in the background with //! stale-while-revalidate semantics. -use axum::{ - extract::State, - http::StatusCode, - response::{IntoResponse, Json, Response}, -}; +use axum::{extract::State, response::Json}; use chrono::{DateTime, Datelike, Duration, NaiveTime, Timelike, Utc}; use chrono_tz::US::Central; use serde::{Deserialize, Serialize}; @@ -21,6 +17,7 @@ use std::collections::{BTreeMap, BTreeSet}; use ts_rs::TS; use crate::state::AppState; +use crate::web::error::ApiError; use crate::web::schedule_cache::weekday_bit; /// 15 minutes in seconds, matching the frontend `SLOT_INTERVAL_MS`. @@ -49,7 +46,11 @@ pub struct TimelineRequest { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct TimeRange { + /// ISO-8601 UTC timestamp (e.g., "2024-01-15T10:30:00Z") + #[ts(type = "string")] start: DateTime, + /// ISO-8601 UTC timestamp (e.g., "2024-01-15T12:30:00Z") + #[ts(type = "string")] end: DateTime, } @@ -67,39 +68,14 @@ pub struct TimelineResponse { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct TimelineSlot { - /// ISO-8601 timestamp at the start of this 15-minute bucket. + /// ISO-8601 UTC timestamp at the start of this 15-minute bucket (e.g., "2024-01-15T10:30:00Z") + #[ts(type = "string")] time: DateTime, /// Subject code → total enrollment in this slot. #[ts(type = "Record")] subjects: BTreeMap, } -// ── Error type ────────────────────────────────────────────────────── - -pub(crate) struct TimelineError { - status: StatusCode, - message: String, -} - -impl TimelineError { - fn bad_request(msg: impl Into) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - message: msg.into(), - } - } -} - -impl IntoResponse for TimelineError { - fn into_response(self) -> Response { - ( - self.status, - Json(serde_json::json!({ "error": self.message })), - ) - .into_response() - } -} - // ── Alignment helpers ─────────────────────────────────────────────── /// Floor a timestamp to the nearest 15-minute boundary. @@ -166,13 +142,13 @@ fn generate_slots(merged: &[AlignedRange]) -> BTreeSet> { pub(crate) async fn timeline( State(state): State, Json(body): Json, -) -> Result, TimelineError> { +) -> Result, ApiError> { // ── Validate ──────────────────────────────────────────────────── if body.ranges.is_empty() { - return Err(TimelineError::bad_request("At least one range is required")); + return Err(ApiError::bad_request("At least one range is required")); } if body.ranges.len() > MAX_RANGES { - return Err(TimelineError::bad_request(format!( + return Err(ApiError::bad_request(format!( "Too many ranges (max {MAX_RANGES})" ))); } @@ -180,14 +156,14 @@ pub(crate) async fn timeline( let mut aligned: Vec = Vec::with_capacity(body.ranges.len()); for r in &body.ranges { if r.end <= r.start { - return Err(TimelineError::bad_request(format!( + return Err(ApiError::bad_request(format!( "Range end ({}) must be after start ({})", r.end, r.start ))); } let span = r.end - r.start; if span > MAX_RANGE_SPAN { - return Err(TimelineError::bad_request(format!( + return Err(ApiError::bad_request(format!( "Range span ({} hours) exceeds maximum ({} hours)", span.num_hours(), MAX_RANGE_SPAN.num_hours() @@ -204,7 +180,7 @@ pub(crate) async fn timeline( // Validate total span let total_span: Duration = merged.iter().map(|r| r.end - r.start).sum(); if total_span > MAX_TOTAL_SPAN { - return Err(TimelineError::bad_request(format!( + return Err(ApiError::bad_request(format!( "Total time span ({} hours) exceeds maximum ({} hours)", total_span.num_hours(), MAX_TOTAL_SPAN.num_hours() diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index b5883b7..938f1ab 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -58,9 +58,9 @@ describe("BannerApiClient", () => { const result = await apiClient.searchCourses({ term: "202420", - subjects: ["CS"], + subject: ["CS"], q: "data", - open_only: true, + openOnly: true, limit: 25, offset: 50, }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2546305..e6843cc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,6 +1,7 @@ import { authStore } from "$lib/auth.svelte"; import type { AdminStatusResponse, + ApiError, AuditLogEntry, AuditLogResponse, CandidateResponse, @@ -15,15 +16,19 @@ import type { LinkedRmpProfile, ListInstructorsResponse, MetricEntry, + MetricsParams as MetricsParamsGenerated, MetricsResponse, RescoreResponse, ScrapeJobDto, ScrapeJobEvent, ScrapeJobsResponse, ScraperStatsResponse, + SearchParams as SearchParamsGenerated, SearchResponse as SearchResponseGenerated, ServiceInfo, ServiceStatus, + SortColumn, + SortDirection, StatusResponse, SubjectDetailResponse, SubjectResultEntry, @@ -45,6 +50,7 @@ const API_BASE_URL = "/api"; // Re-export generated types under their canonical names export type { AdminStatusResponse, + ApiError, AuditLogEntry, AuditLogResponse, CandidateResponse, @@ -67,6 +73,8 @@ export type { ScraperStatsResponse, ServiceInfo, ServiceStatus, + SortColumn, + SortDirection, StatusResponse, SubjectDetailResponse, SubjectResultEntry, @@ -87,34 +95,13 @@ export type Term = TermResponse; export type Subject = CodeDescription; export type ReferenceEntry = CodeDescription; -// SearchResponse re-exported (aliased to strip the "Generated" suffix) +// Re-export with simplified names export type SearchResponse = SearchResponseGenerated; +export type SearchParams = SearchParamsGenerated; +export type MetricsParams = MetricsParamsGenerated; export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d"; -// Client-side only — not generated from Rust -export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats"; -export type SortDirection = "asc" | "desc"; - -export interface MetricsParams { - course_id?: number; - term?: string; - crn?: string; - range?: "1h" | "6h" | "24h" | "7d" | "30d"; - limit?: number; -} - -export interface SearchParams { - term: string; - subjects?: string[]; - q?: string; - open_only?: boolean; - limit?: number; - offset?: number; - sort_by?: SortColumn; - sort_dir?: SortDirection; -} - // Admin instructor query params (client-only, not generated) export interface AdminInstructorListParams { status?: string; @@ -124,6 +111,35 @@ export interface AdminInstructorListParams { sort?: string; } +/** + * API error class that wraps the structured ApiError response from the backend. + */ +export class ApiErrorClass extends Error { + public readonly code: string; + public readonly details: unknown | null; + + constructor(apiError: ApiError) { + super(apiError.message); + this.name = "ApiError"; + this.code = apiError.code; + this.details = apiError.details; + } + + isNotFound(): boolean { + return this.code === "NOT_FOUND"; + } + + isBadRequest(): boolean { + return ( + this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE" + ); + } + + isInternalError(): boolean { + return this.code === "INTERNAL_ERROR"; + } +} + export class BannerApiClient { private baseUrl: string; private fetchFn: typeof fetch; @@ -163,7 +179,17 @@ export class BannerApiClient { } if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + let apiError: ApiError; + try { + apiError = (await response.json()) as ApiError; + } catch { + apiError = { + code: "UNKNOWN_ERROR", + message: `API request failed: ${response.status} ${response.statusText}`, + details: null, + }; + } + throw new ApiErrorClass(apiError); } return (await response.json()) as T; @@ -184,7 +210,17 @@ export class BannerApiClient { } if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + let apiError: ApiError; + try { + apiError = (await response.json()) as ApiError; + } catch { + apiError = { + code: "UNKNOWN_ERROR", + message: `API request failed: ${response.status} ${response.statusText}`, + details: null, + }; + } + throw new ApiErrorClass(apiError); } } @@ -192,20 +228,28 @@ export class BannerApiClient { return this.request("/status"); } - async searchCourses(params: SearchParams): Promise { + async searchCourses(params: Partial & { term: string }): Promise { const query = new URLSearchParams(); query.set("term", params.term); - if (params.subjects) { - for (const s of params.subjects) { + if (params.subject && params.subject.length > 0) { + for (const s of params.subject) { query.append("subject", s); } } if (params.q) query.set("q", params.q); - if (params.open_only) query.set("open_only", "true"); + if (params.openOnly) query.set("open_only", "true"); + if (params.courseNumberLow !== undefined && params.courseNumberLow !== null) { + query.set("course_number_low", String(params.courseNumberLow)); + } + if (params.courseNumberHigh !== undefined && params.courseNumberHigh !== null) { + query.set("course_number_high", String(params.courseNumberHigh)); + } + if (params.instructionalMethod) query.set("instructional_method", params.instructionalMethod); + if (params.campus) query.set("campus", params.campus); if (params.limit !== undefined) query.set("limit", String(params.limit)); if (params.offset !== undefined) query.set("offset", String(params.offset)); - if (params.sort_by) query.set("sort_by", params.sort_by); - if (params.sort_dir) query.set("sort_dir", params.sort_dir); + if (params.sortBy) query.set("sort_by", params.sortBy); + if (params.sortDir) query.set("sort_dir", params.sortDir); return this.request(`/courses/search?${query.toString()}`); } @@ -281,9 +325,11 @@ export class BannerApiClient { }); } - async getMetrics(params?: MetricsParams): Promise { + async getMetrics(params?: Partial): Promise { const query = new URLSearchParams(); - if (params?.course_id !== undefined) query.set("course_id", String(params.course_id)); + if (params?.courseId !== undefined && params.courseId !== null) { + query.set("course_id", String(params.courseId)); + } if (params?.term) query.set("term", params.term); if (params?.crn) query.set("crn", params.crn); if (params?.range) query.set("range", params.range); diff --git a/web/src/lib/bindings/ApiError.ts b/web/src/lib/bindings/ApiError.ts new file mode 100644 index 0000000..293f574 --- /dev/null +++ b/web/src/lib/bindings/ApiError.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Standardized error response for all API endpoints. + */ +export type ApiError = { +/** + * Machine-readable error code (e.g., "NOT_FOUND", "INVALID_TERM") + */ +code: string, +/** + * Human-readable error message + */ +message: string, +/** + * Optional additional details (validation errors, field info, etc.) + */ +details: JsonValue | null, }; diff --git a/web/src/lib/bindings/ListInstructorsParams.ts b/web/src/lib/bindings/ListInstructorsParams.ts new file mode 100644 index 0000000..c5404a4 --- /dev/null +++ b/web/src/lib/bindings/ListInstructorsParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ListInstructorsParams = { status: string | null, search: string | null, page: number | null, perPage: number | null, sort: string | null, }; diff --git a/web/src/lib/bindings/MatchBody.ts b/web/src/lib/bindings/MatchBody.ts new file mode 100644 index 0000000..cf0b0d0 --- /dev/null +++ b/web/src/lib/bindings/MatchBody.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MatchBody = { rmpLegacyId: number, }; diff --git a/web/src/lib/bindings/MetricsParams.ts b/web/src/lib/bindings/MetricsParams.ts new file mode 100644 index 0000000..3645392 --- /dev/null +++ b/web/src/lib/bindings/MetricsParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MetricsParams = { courseId: number | null, term: string | null, crn: string | null, range: string | null, limit: number, }; diff --git a/web/src/lib/bindings/RejectCandidateBody.ts b/web/src/lib/bindings/RejectCandidateBody.ts new file mode 100644 index 0000000..33b9866 --- /dev/null +++ b/web/src/lib/bindings/RejectCandidateBody.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RejectCandidateBody = { rmpLegacyId: number, }; diff --git a/web/src/lib/bindings/SearchParams.ts b/web/src/lib/bindings/SearchParams.ts new file mode 100644 index 0000000..40223b3 --- /dev/null +++ b/web/src/lib/bindings/SearchParams.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SortColumn } from "./SortColumn"; +import type { SortDirection } from "./SortDirection"; + +export type SearchParams = { term: string, subject: Array, q: string | null, courseNumberLow: number | null, courseNumberHigh: number | null, openOnly: boolean, instructionalMethod: string | null, campus: string | null, limit: number, offset: number, sortBy: SortColumn | null, sortDir: SortDirection | null, }; diff --git a/web/src/lib/bindings/SortColumn.ts b/web/src/lib/bindings/SortColumn.ts new file mode 100644 index 0000000..2e16d8d --- /dev/null +++ b/web/src/lib/bindings/SortColumn.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Column to sort search results by. + */ +export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats"; diff --git a/web/src/lib/bindings/SortDirection.ts b/web/src/lib/bindings/SortDirection.ts new file mode 100644 index 0000000..18fd607 --- /dev/null +++ b/web/src/lib/bindings/SortDirection.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Sort direction. + */ +export type SortDirection = "asc" | "desc"; diff --git a/web/src/lib/bindings/StatsParams.ts b/web/src/lib/bindings/StatsParams.ts new file mode 100644 index 0000000..172a912 --- /dev/null +++ b/web/src/lib/bindings/StatsParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type StatsParams = { period: string, }; diff --git a/web/src/lib/bindings/SubjectDetailParams.ts b/web/src/lib/bindings/SubjectDetailParams.ts new file mode 100644 index 0000000..8f78fdd --- /dev/null +++ b/web/src/lib/bindings/SubjectDetailParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubjectDetailParams = { limit: number, }; diff --git a/web/src/lib/bindings/SubjectResultEntry.ts b/web/src/lib/bindings/SubjectResultEntry.ts index 09e420e..d11ea00 100644 --- a/web/src/lib/bindings/SubjectResultEntry.ts +++ b/web/src/lib/bindings/SubjectResultEntry.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SubjectResultEntry = { id: number, completedAt: string, durationMs: number, success: boolean, errorMessage: string | null, coursesFetched: number | null, coursesChanged: number | null, coursesUnchanged: number | null, auditsGenerated: number | null, metricsGenerated: number | null, }; +export type SubjectResultEntry = { id: number, +/** + * ISO-8601 UTC timestamp when the scrape job completed (e.g., "2024-01-15T10:30:00Z") + */ +completedAt: string, durationMs: number, success: boolean, errorMessage: string | null, coursesFetched: number | null, coursesChanged: number | null, coursesUnchanged: number | null, auditsGenerated: number | null, metricsGenerated: number | null, }; diff --git a/web/src/lib/bindings/SubjectSummary.ts b/web/src/lib/bindings/SubjectSummary.ts index a33be46..31b3791 100644 --- a/web/src/lib/bindings/SubjectSummary.ts +++ b/web/src/lib/bindings/SubjectSummary.ts @@ -1,3 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SubjectSummary = { subject: string, subjectDescription: string | null, trackedCourseCount: number, scheduleState: string, currentIntervalSecs: number, timeMultiplier: number, lastScraped: string, nextEligibleAt: string | null, cooldownRemainingSecs: number | null, avgChangeRatio: number, consecutiveZeroChanges: number, recentRuns: number, recentFailures: number, }; +export type SubjectSummary = { subject: string, subjectDescription: string | null, trackedCourseCount: number, scheduleState: string, currentIntervalSecs: number, timeMultiplier: number, +/** + * ISO-8601 UTC timestamp of last scrape (e.g., "2024-01-15T10:30:00Z") + */ +lastScraped: string, +/** + * ISO-8601 UTC timestamp when next scrape is eligible (e.g., "2024-01-15T11:00:00Z") + */ +nextEligibleAt: string | null, cooldownRemainingSecs: number | null, avgChangeRatio: number, consecutiveZeroChanges: number, recentRuns: number, recentFailures: number, }; diff --git a/web/src/lib/bindings/SubjectsParams.ts b/web/src/lib/bindings/SubjectsParams.ts new file mode 100644 index 0000000..6cbae13 --- /dev/null +++ b/web/src/lib/bindings/SubjectsParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubjectsParams = { term: string, }; diff --git a/web/src/lib/bindings/TimeRange.ts b/web/src/lib/bindings/TimeRange.ts index b877ca1..4f46a1f 100644 --- a/web/src/lib/bindings/TimeRange.ts +++ b/web/src/lib/bindings/TimeRange.ts @@ -1,3 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type TimeRange = { start: string, end: string, }; +export type TimeRange = { +/** + * ISO-8601 UTC timestamp (e.g., "2024-01-15T10:30:00Z") + */ +start: string, +/** + * ISO-8601 UTC timestamp (e.g., "2024-01-15T12:30:00Z") + */ +end: string, }; diff --git a/web/src/lib/bindings/TimelineSlot.ts b/web/src/lib/bindings/TimelineSlot.ts index b05b395..3b9f5bd 100644 --- a/web/src/lib/bindings/TimelineSlot.ts +++ b/web/src/lib/bindings/TimelineSlot.ts @@ -2,7 +2,7 @@ export type TimelineSlot = { /** - * ISO-8601 timestamp at the start of this 15-minute bucket. + * ISO-8601 UTC timestamp at the start of this 15-minute bucket (e.g., "2024-01-15T10:30:00Z") */ time: string, /** diff --git a/web/src/lib/bindings/TimeseriesParams.ts b/web/src/lib/bindings/TimeseriesParams.ts new file mode 100644 index 0000000..e05461c --- /dev/null +++ b/web/src/lib/bindings/TimeseriesParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TimeseriesParams = { period: string, bucket: string | null, }; diff --git a/web/src/lib/bindings/TimeseriesPoint.ts b/web/src/lib/bindings/TimeseriesPoint.ts index 2b3d9de..4590a39 100644 --- a/web/src/lib/bindings/TimeseriesPoint.ts +++ b/web/src/lib/bindings/TimeseriesPoint.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type TimeseriesPoint = { timestamp: string, scrapeCount: number, successCount: number, errorCount: number, coursesChanged: number, avgDurationMs: number, }; +export type TimeseriesPoint = { +/** + * ISO-8601 UTC timestamp for this data point (e.g., "2024-01-15T10:00:00Z") + */ +timestamp: string, scrapeCount: number, successCount: number, errorCount: number, coursesChanged: number, avgDurationMs: number, }; diff --git a/web/src/lib/bindings/index.ts b/web/src/lib/bindings/index.ts index f2446a6..decb806 100644 --- a/web/src/lib/bindings/index.ts +++ b/web/src/lib/bindings/index.ts @@ -1,5 +1,6 @@ export type { AdminServiceInfo } from "./AdminServiceInfo"; export type { AdminStatusResponse } from "./AdminStatusResponse"; +export type { ApiError } from "./ApiError"; export type { AuditLogEntry } from "./AuditLogEntry"; export type { AuditLogResponse } from "./AuditLogResponse"; export type { CandidateResponse } from "./CandidateResponse"; @@ -12,29 +13,40 @@ export type { InstructorListItem } from "./InstructorListItem"; export type { InstructorResponse } from "./InstructorResponse"; export type { InstructorStats } from "./InstructorStats"; export type { LinkedRmpProfile } from "./LinkedRmpProfile"; +export type { ListInstructorsParams } from "./ListInstructorsParams"; export type { ListInstructorsResponse } from "./ListInstructorsResponse"; +export type { MatchBody } from "./MatchBody"; export type { MetricEntry } from "./MetricEntry"; +export type { MetricsParams } from "./MetricsParams"; export type { MetricsResponse } from "./MetricsResponse"; export type { OkResponse } from "./OkResponse"; +export type { RejectCandidateBody } from "./RejectCandidateBody"; export type { RescoreResponse } from "./RescoreResponse"; export type { ScrapeJobDto } from "./ScrapeJobDto"; export type { ScrapeJobEvent } from "./ScrapeJobEvent"; export type { ScrapeJobStatus } from "./ScrapeJobStatus"; export type { ScrapeJobsResponse } from "./ScrapeJobsResponse"; export type { ScraperStatsResponse } from "./ScraperStatsResponse"; +export type { SearchParams } from "./SearchParams"; export type { SearchResponse } from "./SearchResponse"; export type { ServiceInfo } from "./ServiceInfo"; export type { ServiceStatus } from "./ServiceStatus"; +export type { SortColumn } from "./SortColumn"; +export type { SortDirection } from "./SortDirection"; +export type { StatsParams } from "./StatsParams"; export type { StatusResponse } from "./StatusResponse"; +export type { SubjectDetailParams } from "./SubjectDetailParams"; export type { SubjectDetailResponse } from "./SubjectDetailResponse"; export type { SubjectResultEntry } from "./SubjectResultEntry"; export type { SubjectSummary } from "./SubjectSummary"; +export type { SubjectsParams } from "./SubjectsParams"; export type { SubjectsResponse } from "./SubjectsResponse"; export type { TermResponse } from "./TermResponse"; export type { TimeRange } from "./TimeRange"; export type { TimelineRequest } from "./TimelineRequest"; export type { TimelineResponse } from "./TimelineResponse"; export type { TimelineSlot } from "./TimelineSlot"; +export type { TimeseriesParams } from "./TimeseriesParams"; export type { TimeseriesPoint } from "./TimeseriesPoint"; export type { TimeseriesResponse } from "./TimeseriesResponse"; export type { TopCandidateResponse } from "./TopCandidateResponse"; diff --git a/web/src/lib/date-utils.ts b/web/src/lib/date-utils.ts new file mode 100644 index 0000000..0211eab --- /dev/null +++ b/web/src/lib/date-utils.ts @@ -0,0 +1,61 @@ +/** + * Utilities for ISO-8601 date string validation and conversion. + * + * All DateTime fields from Rust are serialized as ISO-8601 strings. + */ + +/** + * Validates if a string is a valid ISO-8601 date string. + * + * @param value - The string to validate + * @returns True if the string is a valid ISO-8601 date + */ +export function isValidISODate(value: string): boolean { + try { + const date = new Date(value); + return !isNaN(date.getTime()) && date.toISOString() === value; + } catch { + return false; + } +} + +/** + * Parses an ISO-8601 date string to a Date object. + * + * @param value - The ISO-8601 string to parse + * @returns Date object, or null if invalid + */ +export function parseISODate(value: string): Date | null { + try { + const date = new Date(value); + if (isNaN(date.getTime())) { + return null; + } + return date; + } catch { + return null; + } +} + +/** + * Asserts that a string is a valid ISO-8601 date, throwing if not. + * + * @param value - The string to validate + * @param fieldName - Name of the field for error messages + * @throws Error if the string is not a valid ISO-8601 date + */ +export function assertISODate(value: string, fieldName = "date"): void { + if (!isValidISODate(value)) { + throw new Error(`Invalid ISO-8601 date for ${fieldName}: ${value}`); + } +} + +/** + * Converts a Date to an ISO-8601 UTC string. + * + * @param date - The Date object to convert + * @returns ISO-8601 string in UTC (e.g., "2024-01-15T10:30:00Z") + */ +export function toISOString(date: Date): string { + return date.toISOString(); +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 1936bb1..6f92308 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -212,13 +212,13 @@ async function performSearch( try { const result = await client.searchCourses({ term, - subjects: subjects.length > 0 ? subjects : undefined, + subject: subjects.length > 0 ? subjects : [], q: q || undefined, - open_only: open || undefined, + openOnly: open || false, limit, offset: off, - sort_by: sortBy, - sort_dir: sortDir, + sortBy, + sortDir, }); const applyUpdate = () => {