use axum::{ extract::{Form, Path, Query, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use http::HeaderValue; use julid::Julid; use serde::Deserialize; use sqlx::{query, query_as, query_scalar, SqlitePool}; use super::templates::{AddNewWatchPage, AddWatchButton, GetWatchPage, SearchWatchesPage}; use crate::{ util::{empty_string_as_none, year_to_epoch}, AuthSession, MyWatchesPage, ShowKind, Watch, WatchQuest, }; //-************************************************************************ // Constants //-************************************************************************ const GET_SAVED_WATCHES_QUERY: &str = "select * from watches inner join watch_quests quests on quests.user = $1 and quests.watch = watches.id"; const GET_QUEST_WITH_PUBLICITY_QUERY: &str = "select * from watch_quests where user = ? and watch = ? and public = ?"; const GET_QUEST_WITH_WATCHED_QUERY: &str = "select * from watch_quests where user = ? and watch = ? and watched = ?"; const GET_WATCH_QUERY: &str = "select * from watches where id = $1"; const ADD_WATCH_QUERY: &str = "insert into watches (title, kind, release_date, metadata_url, added_by, length) values ($1, $2, $3, $4, $5, $6) returning id"; const ADD_WATCH_QUEST_QUERY: &str = "insert into watch_quests (user, watch, public, watched) values ($1, $2, $3, $4)"; const EMPTY_SEARCH_QUERY_STRUCT: SearchQuery = SearchQuery { title: None, kind: None, year: None, search: None, }; //-************************************************************************ // Error types for Watch creation //-************************************************************************ #[Error] pub struct AddError(#[from] AddErrorKind); #[Error] #[non_exhaustive] pub enum AddErrorKind { UnknownDBError, NotSignedIn, } impl IntoResponse for AddError { fn into_response(self) -> Response { match &self.0 { AddErrorKind::UnknownDBError => { (StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response() } AddErrorKind::NotSignedIn => ( StatusCode::OK, "Ope, you need to sign in first!".to_string(), ) .into_response(), } } } //-************************************************************************ // Types for receiving arguments from forms //-************************************************************************ #[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)] pub struct SearchQuery { #[serde(default, deserialize_with = "empty_string_as_none")] pub search: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub title: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub kind: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub year: Option, } #[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)] pub struct StatusQuery { pub public: bool, } // kinda the main form? #[derive(Debug, Default, Deserialize, PartialEq, Eq)] pub struct PostAddNewWatch { pub title: String, #[serde(default)] pub private: bool, pub kind: ShowKind, #[serde(default, deserialize_with = "empty_string_as_none")] pub year: Option, // need a date-picker or something #[serde(default, deserialize_with = "empty_string_as_none")] pub metadata_url: Option, #[serde(default)] pub watched_already: bool, } #[derive(Debug, Default, Deserialize, PartialEq, Eq)] pub struct PostAddExistingWatch { pub watch: String, pub public: bool, pub watched_already: bool, } //-************************************************************************ // handlers //-************************************************************************ pub async fn get_add_new_watch(auth: AuthSession) -> impl IntoResponse { AddNewWatchPage { user: auth.user } } /// Add a Watch to your watchlist (side effects system-add) pub async fn post_add_new_watch( auth: AuthSession, State(pool): State, Form(form): Form, ) -> Result { if let Some(user) = auth.user { { let release_date = year_to_epoch(form.year.as_deref()); let watch = Watch { title: form.title, kind: form.kind, metadata_url: form.metadata_url, release_date, added_by: user.id, ..Default::default() }; let watch_id = add_new_watch_impl(&pool, &watch).await?; let quest = WatchQuest { user: user.id, public: !form.private, watched: form.watched_already, watch: watch_id, }; add_watch_quest_impl(&pool, &quest) .await .map_err(|_| AddErrorKind::UnknownDBError)?; let location = format!("/watch/{watch_id}"); Ok(Redirect::to(&location)) } } else { Err(AddErrorKind::NotSignedIn.into()) } } async fn add_new_watch_impl(db_pool: &SqlitePool, watch: &Watch) -> Result { let watch_id: Julid = query_scalar(ADD_WATCH_QUERY) .bind(&watch.title) .bind(watch.kind) .bind(watch.release_date) .bind(&watch.metadata_url) .bind(watch.added_by) .bind(watch.length) .fetch_one(db_pool) .await .map_err(|err| { tracing::error!("Got error: {err}"); AddErrorKind::UnknownDBError })?; Ok(watch_id) } /// Add a Watch to your watchlist by selecting it with a checkbox pub async fn post_add_watch_quest( auth: AuthSession, State(pool): State, Form(form): Form, ) -> Result { if let Some(user) = auth.user { let quest = WatchQuest { user: user.id, watch: Julid::from_string(&form.watch).unwrap(), public: form.public, watched: form.watched_already, }; add_watch_quest_impl(&pool, &quest) .await .map_err(|_| AddErrorKind::UnknownDBError)?; let resp = "✓"; Ok(resp.into_response()) } else { let resp = Redirect::to("/login"); let mut resp = resp.into_response(); resp.headers_mut() .insert("HX-Redirect", HeaderValue::from_str("/login").unwrap()); Ok(resp) } } pub async fn add_watch_quest_impl(pool: &SqlitePool, quest: &WatchQuest) -> Result<(), ()> { query(ADD_WATCH_QUEST_QUERY) .bind(quest.user) .bind(quest.watch) .bind(quest.public) .bind(quest.watched) .execute(pool) .await .map_err(|err| { tracing::error!("Got error: {err}"); })?; Ok(()) } /// A single Watch pub async fn get_watch( auth: AuthSession, watch: Option>, State(pool): State, ) -> impl IntoResponse { let id = if let Some(Path(id)) = watch { id } else { "".to_string() }; let id = id.trim(); let id = Julid::from_string(id).unwrap_or_default(); let q = query_as(GET_WATCH_QUERY).bind(id); let watch: Option = q.fetch_one(&pool).await.ok(); GetWatchPage { watch, user: auth.user, } } /// everything the user has saved pub async fn get_watches(auth: AuthSession, State(pool): State) -> impl IntoResponse { let user = auth.user; let watches: Vec = if (user).is_some() { query_as(GET_SAVED_WATCHES_QUERY) .bind(user.as_ref().unwrap().id) .fetch_all(&pool) .await .unwrap_or_default() } else { vec![] }; MyWatchesPage { watches, user } } pub async fn get_search_watch( auth: AuthSession, State(pool): State, search: Query, ) -> impl IntoResponse { let user = auth.user; let (search_string, qstring) = if search.0 != EMPTY_SEARCH_QUERY_STRUCT { let s = search.0; let q = if let Some(title) = &s.title { title } else if let Some(search) = &s.search { search } else { "" }; (format!("{s:?}"), format!("%{}%", q.trim())) } else { ("".to_string(), "%".to_string()) }; // until tantivy search let watches: Vec = query_as("select * from watches where title like ?") .bind(&qstring) .fetch_all(&pool) .await .unwrap(); SearchWatchesPage { watches, user, search: search_string, } } pub async fn get_watch_status( auth: AuthSession, State(pool): State, query: Query, Path(watch): Path, ) -> Result { if let Some(user) = auth.user { let watch = Julid::from_string(&watch).unwrap(); let public = query.public; let quest: Option = query_as(GET_QUEST_WITH_PUBLICITY_QUERY) .bind(user.id) .bind(watch) .bind(public) .fetch_optional(&pool) .await .map_err(|e| { tracing::error!("Got error from checking watch status: {e:?}"); })?; match quest { Some(_) => Ok("✓".into_response()), None => Ok(AddWatchButton { watch, public, watched_already: false, } .into_response()), } } else { Ok("Login to add".into_response()) } }