use chrono::NaiveDateTime; use serde::Deserialize; use uuid::Uuid; use application::diary::{ commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery, }; use domain::{errors::DomainError, models::SortDirection}; use api_types::{DiaryQueryParams, LogReviewRequest}; pub fn empty_string_as_none<'de, D, T>(de: D) -> Result, D::Error> where D: serde::Deserializer<'de>, T: std::str::FromStr, T::Err: std::fmt::Display, { let s = Option::::deserialize(de)?; match s.as_deref() { None | Some("") => Ok(None), Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), } } #[derive(Deserialize)] pub struct LogReviewForm { #[serde(default, deserialize_with = "empty_string_as_none")] pub external_metadata_id: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub manual_title: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub manual_release_year: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub manual_director: Option, pub rating: u8, #[serde(default, deserialize_with = "empty_string_as_none")] pub comment: Option, pub watched_at: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct LoginForm { pub email: String, pub password: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct RegisterForm { pub email: String, pub username: String, pub password: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct ErrorQuery { pub error: Option, } #[derive(Deserialize, Default)] pub struct FeedQueryParams { #[serde(default)] pub filter: String, #[serde(default)] pub sort_by: String, #[serde(default)] pub search: String, pub limit: Option, pub offset: Option, } #[derive(Deserialize, Default)] pub struct DeleteRedirectForm { #[serde(default)] pub redirect_after: Option, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct FollowForm { pub handle: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, #[serde(default)] pub redirect_after: Option, } #[derive(Deserialize)] pub struct UnfollowForm { pub actor_url: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct FollowerActionForm { pub actor_url: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct BlockDomainForm { pub domain: String, #[serde(default)] pub reason: Option, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct RemoveDomainForm { pub domain: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct ActorUrlForm { pub actor_url: String, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(serde::Deserialize)] pub struct WatchlistAddForm { pub movie_id: Option, pub query: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub year: Option, #[serde(rename = "_csrf", default)] pub csrf_token: String, #[serde(default)] pub redirect_after: Option, } #[derive(serde::Deserialize, Default)] pub struct WatchlistQuery { pub limit: Option, pub offset: Option, pub error: Option, } #[derive(Deserialize, Default)] pub struct ProfileQueryParams { pub view: Option, pub limit: Option, pub offset: Option, pub error: Option, #[serde(default)] pub sort_by: String, #[serde(default)] pub search: String, #[serde(default)] pub embed: bool, } pub struct LogReviewData { pub external_metadata_id: Option, pub manual_title: Option, pub manual_release_year: Option, pub manual_director: Option, pub rating: u8, pub comment: Option, pub watched_at: NaiveDateTime, } #[derive(Debug)] pub struct ParseReviewError { pub field: &'static str, pub message: String, } impl TryFrom for LogReviewData { type Error = ParseReviewError; fn try_from(form: LogReviewForm) -> Result { let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S") .or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M")) .or_else(|_| { chrono::NaiveDate::parse_from_str(&form.watched_at, "%Y-%m-%d") .map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid")) }) .map_err(|_| ParseReviewError { field: "watched_at", message: format!( "invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]", form.watched_at ), })?; Ok(Self { external_metadata_id: form.external_metadata_id.filter(|s| !s.trim().is_empty()), manual_title: form.manual_title, manual_release_year: form.manual_release_year, manual_director: form.manual_director, rating: form.rating, comment: form.comment, watched_at, }) } } impl TryFrom for LogReviewData { type Error = DomainError; fn try_from(req: LogReviewRequest) -> Result { let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S") .map_err(|_| { DomainError::ValidationError( "invalid watched_at; expected YYYY-MM-DDTHH:MM:SS".into(), ) })?; Ok(Self { external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()), manual_title: req.manual_title, manual_release_year: req.manual_release_year, manual_director: req.manual_director, rating: req.rating, comment: req.comment, watched_at, }) } } impl LogReviewData { pub fn into_command(self, user_id: Uuid) -> LogReviewCommand { LogReviewCommand { user_id, input: MovieInput { movie_id: None, external_metadata_id: self.external_metadata_id, manual_title: self.manual_title, manual_release_year: self.manual_release_year, manual_director: self.manual_director, }, rating: self.rating, comment: self.comment, watched_at: self.watched_at, } } } pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery { GetDiaryQuery { limit: p.limit, offset: p.offset, sort_by: p.sort_by.as_deref().map(|s| match s { "date_asc" | "asc" => SortDirection::Ascending, "rating_desc" => SortDirection::ByRatingDesc, "rating_asc" => SortDirection::ByRatingAsc, _ => SortDirection::Descending, }), movie_id: p.movie_id, user_id: None, } } // ── Integrations forms ──────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct GenerateTokenForm { pub provider: String, #[serde(default)] pub label: Option, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct RevokeTokenForm { #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize, Default)] pub struct IntegrationsQuery { pub token: Option, } #[derive(Deserialize)] pub struct ConfirmWatchForm { pub rating: u8, #[serde(default)] pub comment: Option, #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[derive(Deserialize)] pub struct DismissWatchForm { #[serde(rename = "_csrf", default)] pub csrf_token: String, } #[cfg(test)] #[path = "tests/forms.rs"] mod tests;