//! Courses and their weekly time slots. use chrono::NaiveTime; use thiserror::Error; use crate::core::{ controller::ControllerError, models::{ course::{Course, CourseData, CourseId}, slot_tag::SlotTagId, }, }; /// The timetable shows monday to friday... const FIRST_WEEKDAY: u8 = 1; const LAST_WEEKDAY: u8 = 5; /// ...from 8:00 to 19:00: slots must fit in it const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap(); const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap(); impl super::AppController { pub async fn get_courses(&self) -> Result, ControllerError> { self.db.get_courses().await.map_err(Into::into) } pub async fn create_course(&self, course: CourseData) -> Result { let course = self.validate_course(course).await?; self.db.create_course(course).await.map_err(Into::into) } pub async fn update_course( &self, id: CourseId, course: CourseData, ) -> Result { let course = self.validate_course(course).await?; self.db.update_course(id, course).await.map_err(Into::into) } pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> { self.db.delete_course(id).await.map_err(Into::into) } /// Trims the texts, turns blank optional fields into `None`, and checks the rules /// of a course and its slots async fn validate_course(&self, course: CourseData) -> Result { let name = course.name.trim().to_owned(); if name.is_empty() { return Err(CoursesControllerError::EmptyName.into()); } let code = non_blank(course.code); let teachers: Vec = course .teachers .iter() .map(|teacher| teacher.trim().to_owned()) .filter(|teacher| !teacher.is_empty()) .collect(); if teachers.is_empty() { return Err(CoursesControllerError::NoTeacher.into()); } let main_page_url = non_blank(course.main_page_url); if let Some(url) = &main_page_url && !(url.starts_with("http://") || url.starts_with("https://")) { return Err(CoursesControllerError::InvalidMainPageUrl.into()); } let tags = self.db.get_slot_tags().await?; for slot in &course.slots { if !(FIRST_WEEKDAY..=LAST_WEEKDAY).contains(&slot.weekday) { return Err(CoursesControllerError::InvalidWeekday(slot.weekday).into()); } if slot.start_time >= slot.end_time || slot.start_time < DAY_START || slot.end_time > DAY_END { return Err(CoursesControllerError::InvalidSlotTime.into()); } if !tags.iter().any(|tag| tag.id == slot.tag_id) { return Err(CoursesControllerError::UnknownSlotTag(slot.tag_id).into()); } } Ok(CourseData { name, code, teachers, main_page_url, slots: course.slots, }) } } fn non_blank(value: Option) -> Option { value .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) } /// Errors specific to this domain area, turned into status codes by the api #[derive(Error, Debug)] pub enum CoursesControllerError { #[error("The name of the course is empty")] EmptyName, #[error("A course needs at least one teacher")] NoTeacher, #[error("The main page must be an http(s) url")] InvalidMainPageUrl, #[error("Invalid weekday {0}: slots must be from monday (1) to friday (5)")] InvalidWeekday(u8), #[error("A slot must start before it ends, between 8:00 and 19:00")] InvalidSlotTime, #[error("Unknown slot tag {0}")] UnknownSlotTag(SlotTagId), }