diff --git a/crates/api-types/src/auth.rs b/crates/api-types/src/auth.rs index 2f13a4a..0e10667 100644 --- a/crates/api-types/src/auth.rs +++ b/crates/api-types/src/auth.rs @@ -13,6 +13,7 @@ pub struct LoginResponse { pub user_id: Uuid, pub email: String, pub expires_at: String, + pub role: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/api-types/src/import.rs b/crates/api-types/src/import.rs index 8cd797a..6f3302b 100644 --- a/crates/api-types/src/import.rs +++ b/crates/api-types/src/import.rs @@ -45,3 +45,35 @@ pub struct SaveProfileRequest { /// Human-readable profile name (e.g. "Letterboxd") pub name: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(tag = "status")] +pub enum PreviewRowDto { + #[serde(rename = "valid")] + Valid { + index: usize, + title: Option, + release_year: Option, + director: Option, + rating: Option, + watched_at: Option, + comment: Option, + }, + #[serde(rename = "duplicate")] + Duplicate { + index: usize, + title: Option, + release_year: Option, + director: Option, + rating: Option, + watched_at: Option, + comment: Option, + }, + #[serde(rename = "invalid")] + Invalid { index: usize, errors: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ImportPreviewResponse { + pub rows: Vec, +} diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index b04f3fc..e97001b 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -82,6 +82,8 @@ pub struct ProfileResponse { pub username: String, pub bio: Option, pub avatar_url: Option, + pub banner_url: Option, + pub role: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs index 0836522..42cf960 100644 --- a/crates/application/src/auth/login.rs +++ b/crates/application/src/auth/login.rs @@ -10,6 +10,7 @@ pub struct LoginResult { pub user_id: Uuid, pub email: String, pub expires_at: DateTime, + pub role: String, } pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result { @@ -37,6 +38,7 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result, pub avatar_url: Option, + pub banner_url: Option, + pub role: String, } pub async fn execute( @@ -23,10 +25,15 @@ pub async fn execute( let avatar_url = user .avatar_path() .map(|path| format!("{}/images/{}", ctx.config.base_url, path)); + let banner_url = user + .banner_path() + .map(|path| format!("{}/images/{}", ctx.config.base_url, path)); Ok(CurrentProfileData { username: user.username().value().to_string(), bio: user.bio().map(|s| s.to_string()), avatar_url, + banner_url, + role: user.role().as_str().into(), }) } diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index ca63898..62da1cf 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -317,6 +317,15 @@ pub enum UserRole { Admin, } +impl UserRole { + pub fn as_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Admin => "admin", + } + } +} + #[derive(Debug, Clone)] pub struct ProfileField { pub name: String, diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index d7de6f3..acc84a4 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -24,7 +24,7 @@ postgres-federation = [ ] [dependencies] -tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] } +tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] } infer = "0.19.0" percent-encoding = "2" axum-governor = "2" diff --git a/crates/presentation/src/forms.rs b/crates/presentation/src/forms.rs index 15e14f6..dbbe5ab 100644 --- a/crates/presentation/src/forms.rs +++ b/crates/presentation/src/forms.rs @@ -251,12 +251,11 @@ pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery { GetDiaryQuery { limit: p.limit, offset: p.offset, - sort_by: p.sort_by.as_deref().map(|s| { - if s == "asc" { - SortDirection::Ascending - } else { - SortDirection::Descending - } + 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, diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index 68f8446..b6afd9f 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -1,6 +1,6 @@ use api_types::{ - ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse, - SessionStateResponse, + ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto, SaveProfileRequest, + SessionCreatedResponse, SessionStateResponse, }; use axum::{ Extension, Form, @@ -34,6 +34,7 @@ use template_askama::{ use crate::{ csrf::CsrfToken, + errors::ApiError, extractors::{AuthenticatedUser, RequiredCookieUser}, state::AppState, }; @@ -639,64 +640,62 @@ pub async fn api_get_preview( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, Path(session_id_str): Path, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str +) -> Result, ApiError> { + let session_id = session_id_str .parse::() .map(ImportSessionId::from_uuid) - else { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "invalid session id"})), - ) - .into_response(); - }; - match state + .map_err(|_| { + ApiError(domain::errors::DomainError::ValidationError( + "invalid session id".into(), + )) + })?; + + let session = state .app_ctx .repos .import_session .get(&session_id, &user_id) - .await - { - Ok(Some(session)) => { - let annotated: Vec = - session.row_results.unwrap_or_default(); - let rows: Vec = annotated - .iter() - .enumerate() - .map(|(i, a)| { - use domain::models::import::RowResult; - match &a.result { - RowResult::Valid(row) => serde_json::json!({ - "index": i, - "status": if a.is_duplicate { "duplicate" } else { "valid" }, - "title": row.title, - "release_year": row.release_year, - "director": row.director, - "rating": row.rating, - "watched_at": row.watched_at, - "comment": row.comment, - }), - RowResult::Invalid { errors, .. } => serde_json::json!({ - "index": i, - "status": "invalid", - "errors": errors, - }), - } - }) - .collect(); - axum::Json(serde_json::json!({ "rows": rows })).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - axum::Json(serde_json::json!({"error": "session not found"})), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(), - } + .await? + .ok_or_else(|| { + ApiError(domain::errors::DomainError::NotFound( + "session not found".into(), + )) + })?; + + let annotated: Vec = session.row_results.unwrap_or_default(); + let rows = annotated + .iter() + .enumerate() + .map(|(i, a)| { + use domain::models::import::RowResult; + match &a.result { + RowResult::Valid(row) if a.is_duplicate => PreviewRowDto::Duplicate { + index: i, + title: row.title.clone(), + release_year: row.release_year.clone(), + director: row.director.clone(), + rating: row.rating.clone(), + watched_at: row.watched_at.clone(), + comment: row.comment.clone(), + }, + RowResult::Valid(row) => PreviewRowDto::Valid { + index: i, + title: row.title.clone(), + release_year: row.release_year.clone(), + director: row.director.clone(), + rating: row.rating.clone(), + watched_at: row.watched_at.clone(), + comment: row.comment.clone(), + }, + RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid { + index: i, + errors: errors.clone(), + }, + } + }) + .collect(); + + Ok(axum::Json(ImportPreviewResponse { rows })) } #[utoipa::path( diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 4f5b2e1..6693f3e 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -264,7 +264,16 @@ fn cors_layer() -> CorsLayer { } else { let parsed: Vec<_> = origins .split(',') - .filter_map(|s| s.trim().parse().ok()) + .filter_map(|s| { + let trimmed = s.trim(); + match trimmed.parse() { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("ignoring invalid CORS origin {trimmed:?}: {e}"); + None + } + } + }) .collect(); layer .allow_origin(AllowOrigin::list(parsed))