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::{ misc_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_QUERY: &str = "select * from watch_quests where user = ? and watch = ?"; const GET_WATCH_QUERY: &str = "select * from watches where id = $1"; const DEFAULT_WATCHES_QUERY: &str = "select * from (select * from watches order by random() limit 50) order by release_date asc"; 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 CHECKMARK: &str = "✓"; //-************************************************************************ // 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, } // 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, } //-************************************************************************ // 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: false, 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_str(&form.watch).unwrap(), public: form.public, watched: false, }; add_watch_quest_impl(&pool, &quest) .await .map_err(|_| AddErrorKind::UnknownDBError)?; let resp = checkmark(form.public); 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_str(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_query = search.0; let query = if search_query == SearchQuery::default() { query_as(DEFAULT_WATCHES_QUERY) } else if let Some(title) = search_query.title { query_as( "select * from watches where id in (select id from watch_search where title match ? order by rank)", ) .bind(title) } else if let Some(ref search) = search_query.search { query_as("select * from watches where id in (select id from watch_search where title match ?) outer join (select * from stars where id in (select id from star_search where name match ?)) s") .bind(search).bind(search) } else { query_as(DEFAULT_WATCHES_QUERY) }; // until tantivy search let watches: Vec = query.fetch_all(&pool).await.unwrap(); SearchWatchesPage { watches, user } } pub async fn get_watch_status( auth: AuthSession, State(pool): State, Path(watch): Path, ) -> Result { if let Some(user) = auth.user { let watch = Julid::from_str(&watch).unwrap(); let quest: Option = query_as(GET_QUEST_QUERY) .bind(user.id) .bind(watch) .fetch_optional(&pool) .await .map_err(|e| { tracing::error!("Got error from checking watch status: {e:?}"); })?; match quest { Some(quest) if quest.watched => Ok(format!("{CHECKMARK} watched").into_response()), Some(quest) => Ok(checkmark(quest.public).into_response()), None => Ok(AddWatchButton { watch }.into_response()), } } else { Ok("Login to add".into_response()) } } fn checkmark(public: bool) -> String { let public = if public { "public" } else { "private" }; format!("{CHECKMARK} ({public})") }