//! Web API endpoints for calendar export (ICS download + Google Calendar redirect). use axum::{ extract::{Path, State}, http::{StatusCode, header}, response::{IntoResponse, Redirect, Response}, }; use crate::calendar::{CalendarCourse, generate_gcal_url, generate_ics}; use crate::data::models::DbMeetingTime; use crate::state::AppState; /// Fetch course + meeting times, build a `CalendarCourse`. async fn load_calendar_course( state: &AppState, term: &str, crn: &str, ) -> Result<(CalendarCourse, Vec), (StatusCode, String)> { let course = crate::data::courses::get_course_by_crn(&state.db_pool, crn, term) .await .map_err(|e| { tracing::error!(error = %e, "Calendar: course lookup failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Lookup failed".to_string(), ) })? .ok_or_else(|| (StatusCode::NOT_FOUND, "Course not found".to_string()))?; let instructors = crate::data::courses::get_course_instructors(&state.db_pool, course.id) .await .unwrap_or_default(); let primary_instructor = instructors .iter() .find(|i| i.is_primary) .or(instructors.first()) .map(|i| i.display_name.clone()); let meeting_times: Vec = serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(); let cal_course = CalendarCourse { crn: course.crn.clone(), subject: course.subject.clone(), course_number: course.course_number.clone(), title: course.title.clone(), sequence_number: course.sequence_number.clone(), primary_instructor, }; Ok((cal_course, meeting_times)) } /// `GET /api/courses/{term}/{crn}/calendar.ics` /// /// Returns an ICS file download for the course. pub async fn course_ics( State(state): State, Path((term, crn)): Path<(String, String)>, ) -> Result { let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?; if meeting_times.is_empty() { return Err(( StatusCode::NOT_FOUND, "No meeting times found for this course".to_string(), )); } let result = generate_ics(&cal_course, &meeting_times).map_err(|e| { tracing::error!(error = %e, "ICS generation failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate ICS file".to_string(), ) })?; let response = ( [ (header::CONTENT_TYPE, "text/calendar; charset=utf-8"), ( header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", result.filename), ), (header::CACHE_CONTROL, "no-cache"), ], result.content, ) .into_response(); Ok(response) } /// `GET /api/courses/{term}/{crn}/gcal` /// /// Redirects to Google Calendar with a pre-filled event for the first meeting time. /// If multiple meeting times exist, uses the first one with scheduled days/times. pub async fn course_gcal( State(state): State, Path((term, crn)): Path<(String, String)>, ) -> Result { let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?; if meeting_times.is_empty() { return Err(( StatusCode::NOT_FOUND, "No meeting times found for this course".to_string(), )); } // Prefer the first meeting time that has actual days/times scheduled let mt = meeting_times .iter() .find(|mt| { mt.begin_time.is_some() && (mt.monday || mt.tuesday || mt.wednesday || mt.thursday || mt.friday || mt.saturday || mt.sunday) }) .unwrap_or(&meeting_times[0]); let url = generate_gcal_url(&cal_course, mt).map_err(|e| { tracing::error!(error = %e, "Google Calendar URL generation failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate Google Calendar URL".to_string(), ) })?; Ok(Redirect::temporary(&url).into_response()) }