refactor: presentation becomes http-axum adapter, absorbs templates

This commit is contained in:
2026-10-25 21:34:09 +01:00
parent 2875594ed9
commit a931fb548f
94 changed files with 99 additions and 126 deletions

View File

@@ -0,0 +1,15 @@
use std::sync::Arc;
use application::config::AppConfig;
#[derive(Clone)]
pub struct AppContext {
pub deps: Arc<application::Deps>,
pub services: application::Services,
pub config: AppConfig,
pub instance: domain::value_objects::InstanceIdentity,
#[cfg(feature = "federation")]
pub ap_document: Arc<dyn domain::ports::ApDocumentPort>,
#[cfg(feature = "federation")]
pub ap_blocklist: Arc<dyn domain::ports::InstanceBlocklistPort>,
}

View File

@@ -0,0 +1,61 @@
use axum::{
extract::{Request, State},
http::{HeaderValue, header},
middleware::Next,
response::Response,
};
#[derive(Clone)]
pub struct CsrfToken(pub String);
pub fn extract_from_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies
.split(';')
.find_map(|c| c.trim().strip_prefix("csrf=").map(str::to_string))
})
}
/// Router state for [`csrf_middleware`]: whether cookies carry the `Secure` attribute.
#[derive(Clone, Copy)]
pub struct SecureCookies(pub bool);
pub(crate) fn secure_flag(secure_cookies: bool) -> &'static str {
if secure_cookies { "; Secure" } else { "" }
}
pub async fn csrf_middleware(
State(SecureCookies(secure_cookies)): State<SecureCookies>,
mut req: Request,
next: Next,
) -> Response {
let existing = extract_from_cookie(req.headers());
let (token, needs_set) = match existing {
Some(t) => (t, false),
None => (uuid::Uuid::new_v4().to_string(), true),
};
req.extensions_mut().insert(CsrfToken(token.clone()));
let mut response = next.run(req).await;
if needs_set {
let cookie = format!(
"csrf={}; HttpOnly; Path=/; SameSite=Strict{}",
token,
secure_flag(secure_cookies)
);
if let Ok(val) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(header::SET_COOKIE, val);
}
}
response
}
pub fn mismatch(token: &CsrfToken, form_value: &str) -> bool {
token.0 != form_value || form_value.is_empty()
}

View File

@@ -0,0 +1,56 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use domain::errors::DomainError;
pub fn domain_error_status(e: &DomainError) -> StatusCode {
match e {
DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => {
StatusCode::BAD_REQUEST
}
DomainError::NotFound(_) => StatusCode::NOT_FOUND,
DomainError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn domain_error_response(e: DomainError) -> Response {
match &e {
DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
_ => (domain_error_status(&e), e.to_string()).into_response(),
}
}
pub struct ApiError(pub DomainError);
impl From<DomainError> for ApiError {
fn from(err: DomainError) -> Self {
Self(err)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = domain_error_status(&self.0);
match &self.0 {
DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {:?}", self.0);
(
status,
axum::Json(serde_json::json!({"error": "internal server error"})),
)
.into_response()
}
_ => (
status,
axum::Json(serde_json::json!({"error": self.0.to_string()})),
)
.into_response(),
}
}
}

View File

@@ -0,0 +1,159 @@
use axum::{
extract::{FromRef, FromRequestParts},
http::{StatusCode, header, header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Redirect},
};
use domain::{errors::DomainError, value_objects::UserId};
use crate::{errors::ApiError, state::AppState};
pub struct AuthenticatedUser(pub UserId);
impl<S> FromRequestParts<S> for AuthenticatedUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let token = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
ApiError(DomainError::Unauthorized(
"Missing or invalid auth token".into(),
))
})?;
let user_id = app_state
.app_ctx
.services
.auth
.validate_token(token)
.await?;
Ok(AuthenticatedUser(user_id))
}
}
pub struct OptionalCookieUser(pub Option<UserId>);
pub struct RequiredCookieUser(pub UserId);
fn extract_token_from_cookie(parts: &Parts) -> Option<String> {
parts
.headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies
.split(';')
.find_map(|c| c.trim().strip_prefix("token=").map(str::to_string))
})
}
impl<S> FromRequestParts<S> for OptionalCookieUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let Some(token) = extract_token_from_cookie(parts) else {
return Ok(OptionalCookieUser(None));
};
let user_id = app_state
.app_ctx
.services
.auth
.validate_token(&token)
.await
.ok();
Ok(OptionalCookieUser(user_id))
}
}
impl<S> FromRequestParts<S> for RequiredCookieUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = axum::response::Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let token = extract_token_from_cookie(parts)
.ok_or_else(|| Redirect::to("/login").into_response())?;
let user_id = app_state
.app_ctx
.services
.auth
.validate_token(&token)
.await
.map_err(|_| Redirect::to("/login").into_response())?;
Ok(RequiredCookieUser(user_id))
}
}
pub struct AdminApiUser(pub UserId);
impl<S> FromRequestParts<S> for AdminApiUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let AuthenticatedUser(user_id) =
AuthenticatedUser::from_request_parts(parts, state).await?;
let app_state = AppState::from_ref(state);
let is_admin = application::users::authorize_admin::execute(
&app_state.app_ctx.deps.users.authorize_admin,
user_id.value(),
)
.await
.map_err(ApiError)?
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
if is_admin {
Ok(AdminApiUser(user_id))
} else {
Err(ApiError(DomainError::Forbidden("admin only".into())))
}
}
}
pub struct AdminUser(pub UserId);
impl<S> FromRequestParts<S> for AdminUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = axum::response::Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let RequiredCookieUser(user_id) =
RequiredCookieUser::from_request_parts(parts, state).await?;
let is_admin = application::users::authorize_admin::execute(
&app_state.app_ctx.deps.users.authorize_admin,
user_id.value(),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
if is_admin {
Ok(AdminUser(user_id))
} else {
Err(StatusCode::FORBIDDEN.into_response())
}
}
}
#[cfg(test)]
#[path = "tests/extractors.rs"]
pub(crate) mod tests;

View File

@@ -0,0 +1,316 @@
use chrono::NaiveDateTime;
use serde::Deserialize;
use uuid::Uuid;
use application::diary::{
commands::{LogReviewCommand, MovieInput},
queries::GetDiaryQuery,
};
use domain::{errors::DomainError, models::ReviewSortBy};
use api_types::{DiaryQueryParams, LogReviewRequest};
pub fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let s = Option::<String>::deserialize(de)?;
match s.as_deref() {
None | Some("") => Ok(None),
Some(s) => s.parse::<T>().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<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub manual_title: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub manual_release_year: Option<u16>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub manual_director: Option<String>,
pub rating: u8,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub comment: Option<String>,
pub watched_at: String,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub watch_medium: Option<String>,
#[serde(default)]
pub spoilers: bool,
#[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<String>,
}
#[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<u32>,
pub offset: Option<u32>,
}
#[derive(Deserialize, Default)]
pub struct DeleteRedirectForm {
#[serde(default)]
pub redirect_after: Option<String>,
#[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<String>,
}
#[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<String>,
#[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<uuid::Uuid>,
pub query: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub year: Option<u16>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
#[serde(default)]
pub redirect_after: Option<String>,
}
#[derive(serde::Deserialize, Default)]
pub struct WatchlistQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub error: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct ProfileQueryParams {
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub error: Option<String>,
#[serde(default)]
pub sort_by: String,
#[serde(default)]
pub search: String,
#[serde(default)]
pub embed: bool,
}
pub struct LogReviewData {
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: NaiveDateTime,
pub watch_medium: Option<domain::value_objects::WatchMedium>,
pub spoilers: bool,
}
#[derive(Debug)]
pub struct ParseReviewError {
pub field: &'static str,
pub message: String,
}
impl TryFrom<LogReviewForm> for LogReviewData {
type Error = ParseReviewError;
fn try_from(form: LogReviewForm) -> Result<Self, Self::Error> {
let watched_at =
domain::value_objects::parse_watched_at(&form.watched_at).map_err(|_| {
ParseReviewError {
field: "watched_at",
message: format!(
"invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]",
form.watched_at
),
}
})?;
let watch_medium = form
.watch_medium
.map(|s| s.parse())
.transpose()
.map_err(|_| ParseReviewError {
field: "watch_medium",
message: "invalid watch medium".into(),
})?;
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,
watch_medium,
spoilers: form.spoilers,
})
}
}
impl TryFrom<LogReviewRequest> for LogReviewData {
type Error = DomainError;
fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?;
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,
watch_medium: req.watch_medium,
spoilers: req.spoilers,
})
}
}
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,
watch_medium: self.watch_medium,
spoilers: self.spoilers,
}
}
}
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" => ReviewSortBy::Ascending,
"rating_desc" => ReviewSortBy::ByRatingDesc,
"rating_asc" => ReviewSortBy::ByRatingAsc,
_ => ReviewSortBy::Descending,
}),
movie_id: p.movie_id,
user_id: p.user_id,
}
}
// ── Integrations forms ────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct GenerateTokenForm {
pub provider: String,
#[serde(default)]
pub label: Option<String>,
#[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<String>,
}
#[derive(Deserialize)]
pub struct ConfirmWatchForm {
pub rating: u8,
#[serde(default)]
pub comment: Option<String>,
#[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;

View File

@@ -0,0 +1,262 @@
use axum::{
Form, Json,
extract::{Extension, Query, State},
http::{HeaderValue, StatusCode, header::SET_COOKIE},
response::{IntoResponse, Redirect},
};
use chrono::Utc;
use application::auth::{
commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc,
};
use crate::html::templates::{LoginTemplate, RegisterTemplate};
use crate::{
csrf::{CsrfToken, secure_flag},
errors::ApiError,
forms::{ErrorQuery, LoginForm, RegisterForm},
render::render_page,
state::AppState,
};
use api_types::HtmlPageContext;
use api_types::{
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
};
// ── HTML helpers ─────────────────────────────────────────────────────────────
fn set_cookie_header(
token: &str,
max_age: i64,
secure_cookies: bool,
) -> (axum::http::HeaderName, HeaderValue) {
let val = format!(
"token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}",
token,
max_age,
secure_flag(secure_cookies)
);
(
SET_COOKIE,
HeaderValue::from_str(&val).expect("valid cookie"),
)
}
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
post, path = "/api/v1/auth/login",
request_body = LoginRequest,
responses(
(status = 200, body = LoginResponse),
(status = 401, description = "Invalid credentials"),
)
)]
pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, ApiError> {
let result = login_uc::execute(
&state.app_ctx.deps.auth.login,
LoginCommand {
email: req.email,
password: req.password,
},
)
.await?;
Ok(Json(LoginResponse {
token: result.token,
refresh_token: result.refresh_token,
user_id: result.user_id,
email: result.email,
expires_at: result.expires_at.to_rfc3339(),
role: result.role,
}))
}
#[utoipa::path(
post, path = "/api/v1/auth/register",
request_body = RegisterRequest,
responses(
(status = 201, description = "User registered"),
(status = 400, description = "Invalid input"),
)
)]
pub async fn register(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<StatusCode, ApiError> {
register_uc::execute(
&state.app_ctx.deps.auth.register,
RegisterCommand {
email: req.email,
username: req.username,
password: req.password,
role: domain::models::UserRole::Standard,
},
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
post, path = "/api/v1/auth/refresh",
request_body = RefreshRequest,
responses(
(status = 200, body = RefreshResponse),
(status = 401, description = "Invalid or expired refresh token"),
)
)]
pub async fn refresh(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, ApiError> {
let result =
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token)
.await?;
Ok(Json(RefreshResponse {
token: result.token,
refresh_token: result.refresh_token,
expires_at: result.expires_at.to_rfc3339(),
}))
}
#[utoipa::path(
post, path = "/api/v1/auth/logout",
request_body = LogoutRequest,
responses(
(status = 204, description = "Logged out"),
)
)]
pub async fn api_logout(
State(state): State<AppState>,
Json(req): Json<LogoutRequest>,
) -> StatusCode {
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
.await;
StatusCode::NO_CONTENT
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_login_page(
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = HtmlPageContext {
user_email: None,
user_id: None,
is_admin: false,
register_enabled: state.app_ctx.config.allow_registration,
rss_url: "/feed.rss".to_string(),
page_title: "Login — Movies Diary".to_string(),
canonical_url: format!("{}/login", state.app_ctx.config.base_url),
csrf_token: csrf.0,
page_rss_url: None,
pending_follow_count: 0,
};
render_page(LoginTemplate {
ctx: &ctx,
error: params.error.as_deref(),
})
}
pub async fn post_login(
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LoginForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match login_uc::execute(
&state.app_ctx.deps.auth.login,
LoginCommand {
email: form.email,
password: form.password,
},
)
.await
{
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie =
set_cookie_header(&result.token, max_age, state.app_ctx.config.secure_cookies);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(),
}
}
pub async fn get_logout(State(state): State<AppState>) -> impl IntoResponse {
let val = format!(
"token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}",
secure_flag(state.app_ctx.config.secure_cookies)
);
let cookie = (
SET_COOKIE,
HeaderValue::from_str(&val).expect("valid cookie"),
);
([cookie], Redirect::to("/")).into_response()
}
pub async fn get_register_page(
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
if !state.app_ctx.config.allow_registration {
return Redirect::to("/").into_response();
}
let ctx = HtmlPageContext {
user_email: None,
user_id: None,
is_admin: false,
register_enabled: true,
rss_url: "/feed.rss".to_string(),
page_title: "Register — Movies Diary".to_string(),
canonical_url: format!("{}/register", state.app_ctx.config.base_url),
csrf_token: csrf.0,
page_rss_url: None,
pending_follow_count: 0,
};
render_page(RegisterTemplate {
ctx: &ctx,
error: params.error.as_deref(),
})
.into_response()
}
pub async fn post_register(
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<RegisterForm>,
) -> impl IntoResponse {
if !state.app_ctx.config.allow_registration {
return Redirect::to("/").into_response();
}
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match application::auth::register_and_login::execute(
&state.app_ctx.deps.auth.register_and_login,
application::auth::commands::RegisterAndLoginCommand {
email: form.email,
username: form.username,
password: form.password,
},
)
.await
{
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie =
set_cookie_header(&result.token, max_age, state.app_ctx.config.secure_cookies);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => {
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
}
}
}

View File

@@ -0,0 +1,349 @@
use axum::{
Form, Json,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use uuid::Uuid;
use application::diary::{
commands::{DeleteReviewCommand, EditReviewCommand},
delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
queries::GetActivityFeedQuery,
};
use crate::html::templates::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, to_diary_query},
render::render_page,
state::AppState,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse,
EditReviewRequest, ExportQueryParams, LogReviewRequest,
};
use super::helpers::{build_export_response, build_page_context, encode_domain_error};
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/diary",
params(DiaryQueryParams),
responses(
(status = 200, body = DiaryResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_diary(
State(state): State<AppState>,
Query(params): Query<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, ApiError> {
let page =
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?;
Ok(Json(DiaryResponse {
items: page
.items
.iter()
.map(crate::mappers::movies::entry_to_dto)
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
post, path = "/api/v1/reviews",
request_body = LogReviewRequest,
responses(
(status = 201, description = "Review created"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn post_review(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<LogReviewRequest>,
) -> Result<impl IntoResponse, ApiError> {
let data = LogReviewData::try_from(req).map_err(ApiError)?;
log_review::execute(
&state.app_ctx.services.review_logger,
data.into_command(user.0.value()),
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
delete, path = "/api/v1/reviews/{id}",
params(("id" = Uuid, Path, description = "Review ID")),
responses(
(status = 204, description = "Review deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Review not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_review(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let cmd = DeleteReviewCommand {
review_id,
requesting_user_id: user_id.value(),
};
delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch, path = "/api/v1/reviews/{id}",
request_body = EditReviewRequest,
params(("id" = Uuid, Path, description = "Review ID")),
responses(
(status = 200, description = "Review updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Review not found"),
),
security(("bearer_auth" = []))
)]
pub async fn patch_review(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>,
Json(req): Json<EditReviewRequest>,
) -> Result<StatusCode, ApiError> {
let watched_at = req
.watched_at
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
.transpose()?;
let cmd = EditReviewCommand {
review_id,
requesting_user_id: user_id.value(),
rating: req.rating,
comment: req.comment,
watched_at,
watch_medium: req.watch_medium,
spoilers: req.spoilers,
};
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
get, path = "/api/v1/diary/export",
params(ExportQueryParams),
responses(
(status = 200, description = "Diary file download", content_type = "text/csv"),
(status = 400, description = "Invalid format parameter"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn export_diary(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<ExportQueryParams>,
) -> impl IntoResponse {
build_export_response(&params.format, user.0.value(), &state)
}
#[utoipa::path(
get, path = "/api/v1/activity-feed",
params(ActivityFeedQueryParams),
responses((status = 200, body = ActivityFeedResponse)),
)]
pub async fn get_activity_feed(
State(state): State<AppState>,
Query(params): Query<ActivityFeedQueryParams>,
) -> Result<Json<ActivityFeedResponse>, ApiError> {
let page = get_feed_uc::execute(
&state.app_ctx.deps.diary.get_activity_feed,
GetActivityFeedQuery {
limit: params.limit.unwrap_or(20),
offset: params.offset.unwrap_or(0),
sort_by: params
.sort_by
.as_deref()
.map(|s| s.parse().unwrap_or_default())
.unwrap_or_default(),
search: None,
viewer_user_id: None,
filter_following: false,
},
)
.await?;
Ok(Json(ActivityFeedResponse {
items: page
.items
.iter()
.map(crate::mappers::diary::feed_entry_to_dto)
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_new_review_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
ctx.page_title = "Log a Review — Movies Diary".to_string();
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
render_page(NewReviewTemplate {
ctx: &ctx,
error: params.error.as_deref(),
})
}
pub async fn post_review_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LogReviewForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let data = match LogReviewData::try_from(form) {
Ok(d) => d,
Err(_) => {
return Redirect::to("/reviews/new?error=Invalid+date+format").into_response();
}
};
match log_review::execute(
&state.app_ctx.services.review_logger,
data.into_command(user_id.value()),
)
.await
{
Ok(_) => Redirect::to("/").into_response(),
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
}
}
}
pub async fn post_delete_review_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(review_id): Path<Uuid>,
Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = DeleteReviewCommand {
review_id,
requesting_user_id: user_id.value(),
};
match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await {
Ok(()) => {
let redirect_url = form
.redirect_after
.filter(|url| {
(url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?')
})
.unwrap_or_else(|| "/".to_string());
Redirect::to(&redirect_url).into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}
pub async fn get_export_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<api_types::ExportQueryParams>,
) -> impl IntoResponse {
build_export_response(&params.format, user_id.value(), &state)
}
pub async fn get_activity_feed_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Query(params): Query<FeedQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let filter_following = params.filter == "following" && user_id.is_some();
let filter_str = if filter_following { "following" } else { "all" };
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
"rating" => "rating",
"rating_asc" => "rating_asc",
_ => "date",
};
let query = application::diary::queries::GetActivityFeedQuery {
limit,
offset,
sort_by: sort_by_str.parse().unwrap_or_default(),
search: if params.search.is_empty() {
None
} else {
Some(params.search.clone())
},
viewer_user_id: user_id.map(|u| u.value()),
filter_following,
};
match application::diary::get_activity_feed::execute(
&state.app_ctx.deps.diary.get_activity_feed,
query,
)
.await
{
Ok(entries) => {
let entry_limit = entries.limit;
let entry_offset = entries.offset;
let has_more =
(entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count;
let total_pages = (entries.total_count as u32)
.saturating_add(entry_limit.saturating_sub(1))
.checked_div(entry_limit)
.unwrap_or(1);
let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0);
let page_items = build_page_items(total_pages, current_page);
render_page(ActivityFeedTemplate {
entries: entries.items.as_slice(),
current_offset: entry_offset,
limit: entry_limit,
has_more,
ctx: &ctx,
page_items,
filter: filter_str.to_string(),
sort_by: sort_by_str.to_string(),
search: params.search,
})
.into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}

View File

@@ -0,0 +1,205 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use uuid::Uuid;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
UserSettingsDto,
};
// ── Shared mapper ────────────────────────────────────────────────────────────
pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto {
GoalDto {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage(),
is_complete: g.is_complete(),
goal_type: g.goal.goal_type().clone(),
}
}
// ── Goals API ────────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_goals(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery {
user_id: user.0.value(),
},
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
#[utoipa::path(
post, path = "/api/v1/goals",
request_body = CreateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn create_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute(
&state.app_ctx.deps.goals.command,
application::goals::commands::CreateGoalCommand {
user_id: user.0.value(),
year: req.year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
put, path = "/api/v1/goals/{year}",
request_body = UpdateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
Json(req): Json<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute(
&state.app_ctx.deps.goals.command,
application::goals::commands::UpdateGoalCommand {
user_id: user.0.value(),
year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
delete, path = "/api/v1/goals/{year}",
responses(
(status = 204, description = "Goal deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
) -> Result<StatusCode, ApiError> {
application::goals::delete::execute(
&state.app_ctx.deps.goals.command,
application::goals::commands::DeleteGoalCommand {
user_id: user.0.value(),
year,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/users/{id}/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_user_goals(
State(state): State<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
// ── User Settings ────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/settings",
responses(
(status = 200, body = UserSettingsDto),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<UserSettingsDto>, ApiError> {
let settings = application::users::get_settings::execute(
&state.app_ctx.deps.users.get_settings,
user.0.value(),
)
.await?;
Ok(Json(UserSettingsDto {
federate_goals: settings.federate_goals(),
federate_reviews: settings.federate_reviews(),
federate_watchlist: settings.federate_watchlist(),
}))
}
#[utoipa::path(
put, path = "/api/v1/settings",
request_body = UpdateUserSettingsRequest,
responses(
(status = 204, description = "Settings updated"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn update_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute(
&state.app_ctx.deps.users.update_settings,
application::users::update_settings::UpdateUserSettingsCommand {
user_id: user.0.value(),
federate_goals: req.federate_goals,
federate_reviews: req.federate_reviews,
federate_watchlist: req.federate_watchlist,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,190 @@
use api_types::HtmlPageContext;
use domain::value_objects::UserId;
use crate::state::AppState;
pub(crate) fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
/// Error text for a classic-page `?error=` redirect. Infrastructure failures
/// are logged here — the redirect is their only exit, so nothing else would —
/// and shown to the user generically, never with internal detail.
pub(crate) fn encode_domain_error(e: &domain::errors::DomainError) -> String {
match e {
domain::errors::DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {e}");
encode_error("Something went wrong, please try again")
}
_ => encode_error(&e.to_string()),
}
}
pub(crate) fn build_export_response(
format_str: &str,
user_id: uuid::Uuid,
state: &AppState,
) -> axum::response::Response {
use axum::{body::Body, http::StatusCode, response::IntoResponse};
use futures::StreamExt;
use application::diary::{export_diary as export_diary_uc, queries::ExportQuery};
use domain::models::ExportFormat;
let format = match format_str {
"csv" => ExportFormat::Csv,
"json" => ExportFormat::Json,
_ => return StatusCode::BAD_REQUEST.into_response(),
};
let (content_type, filename) = match &format {
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let query = ExportQuery { user_id, format };
let stream = export_diary_uc::execute(&state.app_ctx.deps.diary.export_diary, query);
let stream = stream.map(|r| {
if let Err(ref e) = r {
tracing::error!("diary export stream error: {e}");
}
r
});
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
Body::from_stream(stream),
)
.into_response()
}
pub(crate) struct ProfileFormData {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
pub banner_bytes: Option<Vec<u8>>,
pub banner_content_type: Option<String>,
pub also_known_as: Option<String>,
pub profile_field_names: std::collections::HashMap<usize, String>,
pub profile_field_values: std::collections::HashMap<usize, String>,
}
pub(crate) async fn parse_profile_multipart(
mut multipart: axum::extract::Multipart,
) -> ProfileFormData {
let mut data = ProfileFormData {
display_name: None,
bio: None,
avatar_bytes: None,
avatar_content_type: None,
banner_bytes: None,
banner_content_type: None,
also_known_as: None,
profile_field_names: std::collections::HashMap::new(),
profile_field_values: std::collections::HashMap::new(),
};
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"display_name" => {
if let Ok(text) = field.text().await {
data.display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => {
if let Ok(text) = field.text().await {
data.bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
data.also_known_as = Some(text).filter(|s| !s.is_empty());
}
}
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
data.avatar_bytes = Some(bytes.to_vec());
data.avatar_content_type = ct;
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
data.banner_bytes = Some(bytes.to_vec());
data.banner_content_type = ct;
}
}
n if n.starts_with("field_name_") => {
if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
&& let Ok(text) = field.text().await
&& !text.is_empty()
{
data.profile_field_names.insert(idx, text);
}
}
n if n.starts_with("field_value_") => {
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
&& let Ok(text) = field.text().await
&& !text.is_empty()
{
data.profile_field_values.insert(idx, text);
}
}
_ => {}
}
}
data
}
pub(crate) async fn build_page_context(
state: &AppState,
user_id: Option<UserId>,
csrf_token: String,
) -> HtmlPageContext {
let uuid = user_id.as_ref().map(|u| u.value());
// Page chrome (email/admin badge, pending-follow badge) degrades rather than
// blanking the page: a failed lookup or count logs a warning and falls back
// to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now
// logged and explicit, per the error policy in `get_page_viewer`'s doc comment.
let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id {
match application::users::get_page_viewer::execute(
&state.app_ctx.deps.users.get_page_viewer,
id.value(),
)
.await
{
Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count),
Err(e) => {
tracing::warn!("get_page_viewer failed for {}: {e}", id.value());
(None, false, 0)
}
}
} else {
(None, false, 0)
};
HtmlPageContext {
user_email,
user_id: uuid,
is_admin,
register_enabled: state.app_ctx.config.allow_registration,
rss_url: "/feed.rss".to_string(),
page_title: "Movies Diary".to_string(),
canonical_url: state.app_ctx.config.base_url.clone(),
csrf_token,
page_rss_url: None,
pending_follow_count,
}
}

View File

@@ -0,0 +1,25 @@
use axum::{
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
};
use crate::state::AppState;
pub async fn get_image(
State(state): State<AppState>,
Path(key): Path<String>,
) -> impl IntoResponse {
if key.starts_with("http://") || key.starts_with("https://") {
return axum::response::Redirect::temporary(&key).into_response();
}
match state.app_ctx.services.object_storage.get(&key).await {
Ok(bytes) => {
let mime = infer::get(&bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream");
([(header::CONTENT_TYPE, mime)], bytes).into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}

View File

@@ -0,0 +1,812 @@
use api_types::{
ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto, SaveProfileRequest,
SessionCreatedResponse, SessionStateResponse,
};
use axum::{
Extension, Form,
extract::{Multipart, Path, State},
http::StatusCode,
response::{Html, IntoResponse, Redirect},
};
use serde::Deserialize;
use std::collections::HashMap;
use crate::html::templates::{
ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView,
ImportUploadTemplate,
};
use crate::render::render_page;
use application::import::{
apply_mapping as apply_import_mapping, apply_profile_and_map,
commands::{
ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand,
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
},
create_session as create_import_session, delete_profile as delete_import_profile,
execute as execute_import, get_mapping_stage, get_preview_stage, get_session_state,
list_profiles as list_import_profiles, save_profile as save_import_profile,
};
use domain::errors::DomainError;
use domain::models::{
AnnotatedRow, FieldMapping, FileFormat,
import::{DomainField, Transform},
};
use domain::value_objects::ImportSessionId;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, RequiredCookieUser},
state::AppState,
};
use super::helpers::encode_domain_error;
fn str_to_domain_field(field: &str) -> Option<DomainField> {
match field {
"title" => Some(DomainField::Title),
"release_year" => Some(DomainField::ReleaseYear),
"director" => Some(DomainField::Director),
"rating" => Some(DomainField::Rating),
"watched_at" => Some(DomainField::WatchedAt),
"comment" => Some(DomainField::Comment),
"external_metadata_id" => Some(DomainField::ExternalMetadataId),
_ => None,
}
}
fn parse_mapping_form(form: &HashMap<String, String>) -> Vec<FieldMapping> {
let mut mappings = Vec::new();
let mut i = 0usize;
loop {
if i > 64 {
break;
}
let col_key = format!("mapping_{i}_col");
let Some(col) = form.get(&col_key).cloned() else {
break;
};
let field_str = form
.get(&format!("mapping_{i}_field"))
.map(|s| s.as_str())
.unwrap_or("");
if let Some(domain_field) = str_to_domain_field(field_str) {
let transform = if domain_field == DomainField::Rating {
let scale: f64 = form
.get(&format!("mapping_{i}_scale"))
.and_then(|s| s.parse().ok())
.unwrap_or(1.0);
Transform::RatingScale(scale)
} else if domain_field == DomainField::WatchedAt {
form.get(&format!("mapping_{i}_datefmt"))
.filter(|s| !s.is_empty())
.cloned()
.map(Transform::DateFormat)
.unwrap_or(Transform::Identity)
} else {
Transform::Identity
};
mappings.push(FieldMapping {
source_column: col,
domain_field,
transform,
});
}
i += 1;
}
mappings
}
use crate::mappers::import::annotated_to_preview_row;
// ── HTML wizard handlers ───────────────────────────────────────────────────
pub async fn get_import_page(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
let profiles =
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id)
.await
.unwrap_or_default()
.into_iter()
.map(|p| ImportProfileView {
id: p.id.value().to_string(),
name: p.name,
})
.collect::<Vec<_>>();
render_page(ImportUploadTemplate {
ctx: &ctx,
profiles: &profiles,
error: None,
})
}
pub async fn post_upload(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut file_bytes: Option<Vec<u8>> = None;
let mut format_str = "csv".to_string();
while let Ok(Some(field)) = multipart.next_field().await {
match field.name() {
Some("file") => {
if let Ok(bytes) = field.bytes().await {
file_bytes = Some(bytes.to_vec());
}
}
Some("format") => {
if let Ok(text) = field.text().await {
format_str = text;
}
}
_ => {}
}
}
let bytes = match file_bytes {
Some(b) if !b.is_empty() => b,
_ => return Redirect::to("/import?error=no+file+provided").into_response(),
};
let format = match format_str.as_str() {
"json" => FileFormat::Json,
"xlsx" => FileFormat::Xlsx,
_ => FileFormat::Csv,
};
match create_import_session::execute(
&state.app_ctx.deps.import.create_session,
CreateImportSessionCommand {
user_id: user_id.value(),
bytes,
format,
},
)
.await
{
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
}
}
}
pub async fn get_mapping_page(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
let Ok(stage) = get_mapping_stage::execute(
&state.app_ctx.deps.import.get_mapping_stage,
session_id,
user_id.value(),
)
.await
else {
return Redirect::to("/import").into_response();
};
let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
let domain_fields: Vec<(&str, &str)> = vec![
("title", "Title"),
("release_year", "Release Year"),
("director", "Director"),
("rating", "Rating"),
("watched_at", "Watched At"),
("comment", "Comment"),
("external_metadata_id", "External ID"),
];
render_page(ImportMappingTemplate {
ctx: &ctx,
session_id: &session_id_str,
columns: &stage.columns,
sample_rows: &stage.sample_rows,
domain_fields: &domain_fields,
error: None,
})
.into_response()
}
pub async fn post_mapping(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
if crate::csrf::mismatch(&csrf, csrf_token) {
return Redirect::to("/import").into_response();
}
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
let mappings = parse_mapping_form(&form);
if mappings.is_empty() {
return Redirect::to(&format!(
"/import/{}/mapping?error=select+at+least+one+mapping",
session_id_str
))
.into_response();
}
match apply_import_mapping::execute(
&state.app_ctx.deps.import.apply_mapping,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id: session_id.value(),
mappings,
},
)
.await
{
Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(),
Err(e) => Redirect::to(&format!(
"/import/{}/mapping?error={}",
session_id_str,
encode_domain_error(&e)
))
.into_response(),
}
}
pub async fn get_preview_page(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
let Ok(stage) = get_preview_stage::execute(
&state.app_ctx.deps.import.get_preview_stage,
session_id,
user_id.value(),
)
.await
else {
return Redirect::to("/import").into_response();
};
let preview = match stage {
get_preview_stage::PreviewStage::Ready(preview) => preview,
get_preview_stage::PreviewStage::NotYetMapped => {
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
}
};
let rows: Vec<ImportPreviewRow> = preview
.rows
.iter()
.enumerate()
.map(|(i, a)| annotated_to_preview_row(i, a))
.collect();
let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
render_page(ImportPreviewTemplate {
ctx: &ctx,
session_id: &session_id_str,
columns: &preview.columns,
rows: &rows,
})
.into_response()
}
pub async fn post_confirm(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form_entries): Form<Vec<(String, String)>>,
) -> impl IntoResponse {
let csrf_token = form_entries
.iter()
.find(|(k, _)| k == "_csrf")
.map(|(_, v)| v.as_str())
.unwrap_or("");
if crate::csrf::mismatch(&csrf, csrf_token) {
return Redirect::to("/import").into_response();
}
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
// Save profile if name provided
let profile_name = form_entries
.iter()
.find(|(k, _)| k == "profile_name")
.map(|(_, v)| v.clone())
.filter(|n| !n.trim().is_empty());
if let Some(name) = profile_name {
let _ = save_import_profile::execute(
&state.app_ctx.deps.import.save_profile,
SaveImportProfileCommand {
user_id: user_id.value(),
session_id: session_id.value(),
name,
},
)
.await;
}
// Collect all "confirmed" checkbox values
let confirmed: Vec<usize> = form_entries
.iter()
.filter(|(k, _)| k == "confirmed")
.filter_map(|(_, v)| v.parse::<usize>().ok())
.collect();
match execute_import::execute(
&state.app_ctx.deps.import.execute_import,
ExecuteImportCommand {
user_id: user_id.value(),
session_id: session_id.value(),
confirmed_indices: confirmed,
},
)
.await
{
Ok(summary) => Redirect::to(&format!(
"/import/done?imported={}&skipped={}&failed={}",
summary.imported,
summary.skipped_duplicates,
summary.failed.len()
))
.into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
}
}
}
pub async fn post_delete_profile(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(profile_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
if crate::csrf::mismatch(&csrf, csrf_token) {
return Redirect::to("/import").into_response();
}
if let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() {
let _ = delete_import_profile::execute(
&state.app_ctx.deps.import.delete_profile,
DeleteImportProfileCommand {
user_id: user_id.value(),
profile_id,
},
)
.await;
}
Redirect::to("/import").into_response()
}
#[derive(Deserialize)]
pub struct ImportDoneParams {
pub imported: Option<usize>,
pub skipped: Option<usize>,
pub failed: Option<usize>,
}
pub async fn get_import_done(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
axum::extract::Query(params): axum::extract::Query<ImportDoneParams>,
) -> impl IntoResponse {
let _ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
let html = format!(
r#"<!doctype html><html><body>
<h1>Import Complete</h1>
<p>Imported: {}</p>
<p>Skipped duplicates: {}</p>
<p>Failed: {}</p>
<a href="/users/{}">Go to My Profile</a>
</body></html>"#,
params.imported.unwrap_or(0),
params.skipped.unwrap_or(0),
params.failed.unwrap_or(0),
user_id.value(),
);
Html(html)
}
// ── REST API handlers ──────────────────────────────────────────────────────
#[utoipa::path(
post, path = "/api/v1/import/sessions",
request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"),
responses(
(status = 200, body = SessionCreatedResponse),
(status = 400, description = "No file provided"),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Parse error"),
),
security(("bearer_auth" = []))
)]
pub async fn api_post_session(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> Result<impl IntoResponse, ApiError> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut format_str = "csv".to_string();
while let Ok(Some(field)) = multipart.next_field().await {
match field.name() {
Some("file") => {
if let Ok(b) = field.bytes().await {
file_bytes = Some(b.to_vec());
}
}
Some("format") => {
if let Ok(t) = field.text().await {
format_str = t;
}
}
_ => {}
}
}
let bytes = match file_bytes {
Some(b) if !b.is_empty() => b,
_ => return Err(DomainError::ValidationError("no file".into()).into()),
};
let format = match format_str.as_str() {
"json" => FileFormat::Json,
"xlsx" => FileFormat::Xlsx,
_ => FileFormat::Csv,
};
let r = create_import_session::execute(
&state.app_ctx.deps.import.create_session,
CreateImportSessionCommand {
user_id: user_id.value(),
bytes,
format,
},
)
.await?;
Ok(axum::Json(SessionCreatedResponse {
session_id: r.session_id.value().to_string(),
columns: r.columns,
sample_rows: r.sample_rows,
}))
}
#[utoipa::path(
get, path = "/api/v1/import/sessions/{id}",
params(("id" = String, Path, description = "Import session UUID")),
responses(
(status = 200, body = SessionStateResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Session not found"),
),
security(("bearer_auth" = []))
)]
pub async fn api_get_session(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let state_data = get_session_state::execute(
&state.app_ctx.deps.import.get_session_state,
session_id,
user_id.value(),
)
.await?;
Ok(axum::Json(SessionStateResponse {
session_id: session_id_str,
columns: state_data.columns,
has_mappings: state_data.has_mappings,
row_count: state_data.row_count,
}))
}
#[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/mapping",
params(("id" = String, Path, description = "Import session UUID")),
request_body = ApplyMappingRequest,
responses(
(status = 200, description = "Mapping applied", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Mapping error"),
),
security(("bearer_auth" = []))
)]
pub async fn api_put_mapping(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ApplyMappingRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let mappings: Vec<FieldMapping> = body
.mappings
.into_iter()
.filter_map(|m| {
let domain_field = str_to_domain_field(&m.domain_field)?;
let transform = if domain_field == DomainField::Rating {
Transform::RatingScale(m.rating_scale.unwrap_or(1.0))
} else if domain_field == DomainField::WatchedAt {
m.date_format
.map(Transform::DateFormat)
.unwrap_or(Transform::Identity)
} else {
Transform::Identity
};
Some(FieldMapping {
source_column: m.source_column,
domain_field,
transform,
})
})
.collect();
let rows = apply_import_mapping::execute(
&state.app_ctx.deps.import.apply_mapping,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id: session_id.value(),
mappings,
},
)
.await?;
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
}
pub async fn api_get_preview(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let stage = get_preview_stage::execute(
&state.app_ctx.deps.import.get_preview_stage,
session_id,
user_id.value(),
)
.await?;
// Not-yet-mapped sessions render as an empty preview here (200, `rows: []`),
// matching this endpoint's behavior before the stage-gate moved into
// `get_preview_stage` — unlike the HTML handler, this API route never
// redirected on the same condition, so its response shape is unchanged.
let annotated: Vec<AnnotatedRow> = match stage {
get_preview_stage::PreviewStage::Ready(preview) => preview.rows,
get_preview_stage::PreviewStage::NotYetMapped => Vec::new(),
};
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(api_types::PreviewRowData {
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(api_types::PreviewRowData {
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(
post, path = "/api/v1/import/sessions/{id}/confirm",
params(("id" = String, Path, description = "Import session UUID")),
request_body = ConfirmRequest,
responses(
(status = 200, description = "Import summary", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Session not found"),
),
security(("bearer_auth" = []))
)]
pub async fn api_post_confirm(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ConfirmRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let s = execute_import::execute(
&state.app_ctx.deps.import.execute_import,
ExecuteImportCommand {
user_id: user_id.value(),
session_id: session_id.value(),
confirmed_indices: body.confirmed_indices,
},
)
.await?;
Ok(axum::Json(serde_json::json!({
"imported": s.imported,
"skipped_duplicates": s.skipped_duplicates,
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
})))
}
#[utoipa::path(
get, path = "/api/v1/import/profiles",
responses(
(status = 200, description = "List of saved import profiles", body = inline(Vec<serde_json::Value>)),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn api_get_profiles(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<impl IntoResponse, ApiError> {
let profiles =
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?;
Ok(axum::Json(
profiles
.into_iter()
.map(|p| {
serde_json::json!({
"id": p.id.value().to_string(),
"name": p.name,
"created_at": p.created_at.to_string(),
})
})
.collect::<Vec<_>>(),
))
}
#[utoipa::path(
post, path = "/api/v1/import/profiles",
request_body = SaveProfileRequest,
responses(
(status = 200, description = "Profile saved", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Session has no mapping yet"),
),
security(("bearer_auth" = []))
)]
pub async fn api_post_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<SaveProfileRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = body
.session_id
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let id = save_import_profile::execute(
&state.app_ctx.deps.import.save_profile,
SaveImportProfileCommand {
user_id: user_id.value(),
session_id: session_id.value(),
name: body.name,
},
)
.await?;
Ok(axum::Json(
serde_json::json!({"id": id.value().to_string()}),
))
}
#[utoipa::path(
delete, path = "/api/v1/import/profiles/{id}",
params(("id" = String, Path, description = "Import profile UUID")),
responses(
(status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
),
security(("bearer_auth" = []))
)]
pub async fn api_delete_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(profile_id_str): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let profile_id = profile_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
delete_import_profile::execute(
&state.app_ctx.deps.import.delete_profile,
DeleteImportProfileCommand {
user_id: user_id.value(),
profile_id,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
params(
("id" = String, Path, description = "Import session UUID"),
("profile_id" = String, Path, description = "Import profile UUID"),
),
responses(
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
(status = 400, description = "Invalid ID"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Session or profile not found"),
(status = 422, description = "Mapping error"),
),
security(("bearer_auth" = []))
)]
pub async fn api_apply_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path((session_id_str, profile_id_str)): Path<(String, String)>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let profile_id = profile_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
let rows = apply_profile_and_map::execute(
&state.app_ctx.deps.import.apply_profile_and_map,
ApplyProfileAndMapCommand {
user_id: user_id.value(),
session_id,
profile_id,
},
)
.await?;
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
}

View File

@@ -0,0 +1,95 @@
use axum::{
Json,
extract::{Query, State},
};
use application::insights::get as get_insights_uc;
use domain::{errors::DomainError, models::Insight};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
fn to_dto(insight: Insight) -> InsightDto {
match insight {
Insight::RatingLift {
genre,
genre_avg,
user_avg,
delta,
sample_count,
} => InsightDto::RatingLift {
genre,
genre_avg,
user_avg,
delta,
sample_count,
},
Insight::Seasonality {
genre,
month,
multiplier,
sample_count,
} => InsightDto::Seasonality {
genre,
month,
multiplier,
sample_count,
},
Insight::MediumTiming {
medium,
multiplier,
weekend_share,
baseline_share,
sample_count,
} => InsightDto::MediumTiming {
medium,
multiplier,
weekend_share,
baseline_share,
sample_count,
},
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
} => InsightDto::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
},
}
}
/// Live insights for the authenticated user's own diary. Private surface:
/// habits (late-night watching, mediums) are more sensitive than the taste
/// statistics already public on profiles (ADR-0010).
#[utoipa::path(
get, path = "/api/v1/insights",
params(InsightsQueryParams),
responses(
(status = 200, body = InsightsResponse),
(status = 400, description = "Unknown time zone"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_insights(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<InsightsQueryParams>,
) -> Result<Json<InsightsResponse>, ApiError> {
let tz: chrono_tz::Tz = match params.tz.as_deref() {
None => chrono_tz::UTC,
Some(name) => name.parse().map_err(|_| {
ApiError(DomainError::ValidationError(format!(
"unknown time zone: {name}"
)))
})?,
};
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0, &tz).await?;
Ok(Json(InsightsResponse {
insights: insights.into_iter().map(to_dto).collect(),
}))
}

View File

@@ -0,0 +1,218 @@
use axum::{
Form,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use uuid::Uuid;
use application::integrations::{
commands::{
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
RevokeWebhookTokenCommand, WatchEventConfirmation,
},
confirm as confirm_watch_events, dismiss as dismiss_watch_events,
generate_token as generate_webhook_token, get_queue as get_watch_queue,
get_tokens as get_webhook_tokens,
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
revoke_token as revoke_webhook_token,
};
use crate::html::templates::{IntegrationsTemplate, WatchQueueTemplate};
use crate::{
csrf::CsrfToken, extractors::RequiredCookieUser, forms::ErrorQuery, render::render_page,
state::AppState,
};
use super::helpers::{build_page_context, encode_domain_error};
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_integrations_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<crate::forms::IntegrationsQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Integrations — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/integrations", state.app_ctx.config.base_url);
let query = GetWebhookTokensQuery {
user_id: user_id.value(),
};
let tokens =
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
.await
.unwrap_or_default();
let token_views: Vec<crate::html::templates::WebhookTokenView> = tokens
.iter()
.map(crate::mappers::integrations::webhook_token_view)
.collect();
let webhook_base_url = state.app_ctx.config.base_url.clone();
render_page(IntegrationsTemplate {
ctx: &ctx,
tokens: &token_views,
webhook_base_url: &webhook_base_url,
new_token: params.token.as_deref(),
})
.into_response()
}
pub async fn post_generate_token(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::GenerateTokenForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let provider = match form.provider.parse::<domain::models::WatchEventSource>() {
Ok(p) => p,
Err(_) => return Redirect::to("/settings/integrations").into_response(),
};
let cmd = GenerateWebhookTokenCommand {
user_id: user_id.value(),
provider,
label: form.label.filter(|l| !l.trim().is_empty()),
};
match generate_webhook_token::execute(
&state.app_ctx.deps.integrations.generate_webhook_token,
cmd,
)
.await
{
Ok(result) => {
let encoded = percent_encoding::utf8_percent_encode(
&result.token_plaintext,
percent_encoding::NON_ALPHANUMERIC,
);
Redirect::to(&format!("/settings/integrations?token={encoded}")).into_response()
}
Err(e) => {
tracing::error!("generate token failed: {:?}", e);
Redirect::to("/settings/integrations").into_response()
}
}
}
pub async fn post_revoke_token(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(token_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::RevokeTokenForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = RevokeWebhookTokenCommand {
user_id: user_id.value(),
token_id,
};
if let Err(e) =
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
.await
{
tracing::error!("revoke token failed: {:?}", e);
}
Redirect::to("/settings/integrations").into_response()
}
// ── Watch Queue ──────────────────────────────────────────────────────────────
pub async fn get_watch_queue_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Watch Queue — Movies Diary".to_string();
ctx.canonical_url = format!("{}/watch-queue", state.app_ctx.config.base_url);
let query = GetWatchQueueQuery {
user_id: user_id.value(),
};
let events = get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query)
.await
.unwrap_or_default();
let entries: Vec<crate::html::templates::WatchQueueDisplayEntry> = events
.iter()
.map(crate::mappers::integrations::watch_queue_entry)
.collect();
render_page(WatchQueueTemplate {
ctx: &ctx,
entries: &entries,
error: params.error.as_deref(),
})
.into_response()
}
pub async fn post_confirm_single(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::ConfirmWatchForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = ConfirmWatchEventsCommand {
user_id: user_id.value(),
confirmations: vec![WatchEventConfirmation {
watch_event_id: event_id,
rating: form.rating,
comment: form.comment.filter(|c| !c.trim().is_empty()),
}],
};
match confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
.await
{
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
}
}
}
pub async fn post_dismiss_single(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::DismissWatchForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = DismissWatchEventsCommand {
user_id: user_id.value(),
event_ids: vec![event_id],
};
match dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
.await
{
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
}
}
}

View File

@@ -0,0 +1,19 @@
pub mod auth;
pub mod diary;
pub mod goals;
mod helpers;
pub mod images;
pub mod import;
pub mod insights;
pub mod integrations;
pub mod movies;
pub mod rss;
pub mod search;
pub mod social;
pub mod users;
pub mod watchlist;
pub mod webhook;
pub mod wrapup;
const DEFAULT_PAGE_LIMIT: u32 = 5;
const RSS_FEED_LIMIT: u32 = 50;

View File

@@ -0,0 +1,328 @@
use axum::{
Json,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use uuid::Uuid;
use application::{
diary::{
commands::SyncPosterCommand,
get_movie_social_page, get_review_history,
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
},
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
};
use domain::services::review_history::Trend;
use crate::html::templates::MovieDetailTemplate;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, OptionalCookieUser},
render::render_page,
state::AppState,
};
use api_types::{
CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse,
MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto,
};
use super::helpers::build_page_context;
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/movies",
params(MoviesQueryParams),
responses(
(status = 200, body = MoviesResponse),
)
)]
pub async fn list_movies(
State(state): State<AppState>,
Query(params): Query<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, ApiError> {
let page = get_movies::execute(
&state.app_ctx.deps.movies.get_movies,
GetMoviesQuery {
limit: params.limit,
offset: params.offset,
search: params.search,
genre: params.genre,
language: params.language,
},
)
.await?;
Ok(Json(MoviesResponse {
items: page
.items
.iter()
.map(crate::mappers::movies::summary_to_dto)
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
get, path = "/api/v1/movies/{id}/history",
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = ReviewHistoryResponse),
(status = 404, description = "Movie not found"),
)
)]
pub async fn get_review_history(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
let (history, trend) = get_review_history::execute(
&state.app_ctx.deps.diary.get_review_history,
GetReviewHistoryQuery { movie_id },
)
.await?;
Ok(Json(ReviewHistoryResponse {
movie: crate::mappers::movies::movie_to_dto(history.movie()),
viewings: history
.viewings()
.iter()
.map(crate::mappers::movies::review_to_dto)
.collect(),
trend: match trend {
Trend::Improved => "improved",
Trend::Declined => "declined",
Trend::Neutral => "neutral",
}
.to_string(),
}))
}
#[utoipa::path(
post, path = "/api/v1/movies/{id}/sync-poster",
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 204, description = "Poster synced"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Movie not found"),
),
security(("bearer_auth" = []))
)]
pub async fn sync_poster(
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
sync_poster::execute(
&state.app_ctx.deps.movies.sync_poster,
SyncPosterCommand { movie_id },
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/movies/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieDetailResponse),
(status = 404, description = "Movie not found"),
)
)]
pub async fn get_movie_detail(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, ApiError> {
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let result = get_movie_social_page::execute(
&state.app_ctx.deps.diary.get_movie_social_page,
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await?;
Ok(Json(MovieDetailResponse {
movie: crate::mappers::movies::movie_to_dto(&result.movie),
stats: MovieStatsDto {
total_count: result.stats.total_count,
avg_rating: result.stats.avg_rating,
federated_count: result.stats.federated_count,
rating_histogram: result.stats.rating_histogram,
},
reviews: SocialFeedResponse {
items: result
.reviews
.items
.iter()
.map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
user_id: e.review().user_id().value(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
is_federated: e.review().is_remote(),
watch_medium: e.review().watch_medium().copied(),
spoilers: e.review().spoilers(),
})
.collect(),
total_count: result.reviews.total_count,
limit: result.reviews.limit,
offset: result.reviews.offset,
},
}))
}
#[utoipa::path(
get, path = "/api/v1/movies/{id}/profile",
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieProfileResponse),
(status = 404, description = "No profile found for this movie"),
)
)]
pub async fn get_movie_profile(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
) -> impl IntoResponse {
use application::movies::get_movie_profile;
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
match get_movie_profile::execute(&state.app_ctx.deps.movies.get_movie_profile, query).await {
Ok(Some(result)) => {
let p = result.profile;
Json(MovieProfileResponse {
tmdb_id: p.tmdb_id,
imdb_id: p.imdb_id,
overview: p.overview,
tagline: p.tagline,
runtime_minutes: p.runtime_minutes,
budget_usd: p.budget_usd,
revenue_usd: p.revenue_usd,
vote_average: p.vote_average,
vote_count: p.vote_count,
original_language: p.original_language,
collection_name: p.collection_name,
genres: p
.genres
.into_iter()
.map(|g| GenreDto {
tmdb_id: g.tmdb_id,
name: g.name,
})
.collect(),
keywords: p
.keywords
.into_iter()
.map(|k| KeywordDto {
tmdb_id: k.tmdb_id,
name: k.name,
})
.collect(),
cast: result
.cast
.into_iter()
.map(|c| CastMemberDto {
person_id: c.person_id.value().to_string(),
tmdb_person_id: c.tmdb_person_id,
name: c.name,
character: c.character,
billing_order: c.billing_order,
profile_path: c.profile_path,
})
.collect(),
crew: result
.crew
.into_iter()
.map(|c| CrewMemberDto {
person_id: c.person_id.value().to_string(),
tmdb_person_id: c.tmdb_person_id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
})
.collect(),
enriched_at: p.enriched_at.to_rfc3339(),
})
.into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_movie_detail_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
match get_movie_social_page::execute(
&state.app_ctx.deps.diary.get_movie_social_page,
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await
{
Err(e) => crate::errors::domain_error_response(e),
Ok(result) => {
let histogram_max = result
.stats
.rating_histogram
.iter()
.copied()
.max()
.unwrap_or(1);
let has_more =
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
let on_watchlist = match &user_id {
Some(uid) => is_on_watchlist::execute(
&state.app_ctx.deps.watchlist.is_on_watchlist,
IsOnWatchlistQuery {
user_id: uid.value(),
movie_id,
},
)
.await
.unwrap_or(false),
None => false,
};
let current_offset = result.reviews.offset;
let reviews_limit = result.reviews.limit;
render_page(MovieDetailTemplate {
ctx: &ctx,
movie: &result.movie,
stats: &result.stats,
profile: result.profile.as_ref(),
reviews: result.reviews.items.as_slice(),
on_watchlist,
current_offset,
has_more,
limit: reviews_limit,
histogram_max,
})
.into_response()
}
}
}

View File

@@ -0,0 +1,70 @@
use axum::{
extract::{Path, State},
http::header,
response::IntoResponse,
};
use uuid::Uuid;
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery};
use domain::{models::ReviewSortBy, ports::FeedChannel};
use crate::{errors::ApiError, state::AppState};
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let query = GetDiaryQuery {
limit: Some(super::RSS_FEED_LIMIT),
offset: Some(0),
sort_by: Some(ReviewSortBy::Descending),
movie_id: None,
user_id: None,
};
let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, query).await?;
let xml = state
.rss_renderer
.render_feed(
&page.items,
&FeedChannel {
title: "Movie Diary",
link_path: "/",
self_path: "/feed.rss",
},
)
.map_err(ApiError)?;
Ok((
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
xml,
))
}
pub async fn get_user_feed(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let feed = get_user_feed::execute(
&state.app_ctx.deps.diary.get_user_feed,
user_id,
super::RSS_FEED_LIMIT,
)
.await?;
let title = format!("{}'s Movie Diary", feed.author.display_name);
let link_path = format!("/users/{user_id}");
let self_path = format!("/users/{user_id}/feed.rss");
let xml = state
.rss_renderer
.render_feed(
&feed.entries,
&FeedChannel {
title: &title,
link_path: &link_path,
self_path: &self_path,
},
)
.map_err(ApiError)?;
Ok((
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
xml,
))
}

View File

@@ -0,0 +1,190 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use application::{
person::{get as get_person, get_credits as get_person_credits},
search::execute as search_uc,
};
use domain::models::{PersonId, collections::PageParams};
use crate::state::AppState;
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
};
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/search",
params(api_types::search::SearchQueryParams),
responses(
(status = 200, body = api_types::search::SearchResponse),
),
tag = "search",
)]
pub async fn get_search(
State(state): State<AppState>,
Query(params): Query<SearchQueryParams>,
) -> impl IntoResponse {
let query = domain::models::SearchQuery {
text: params.q,
filters: domain::models::SearchFilters {
genre: params.genre,
year: params.year,
person_id: params.person_id.map(PersonId::from_uuid),
department: params.department,
language: params.language,
},
page: PageParams {
limit: params.limit.unwrap_or(5),
offset: params.offset.unwrap_or(0),
},
};
match search_uc::execute(&state.app_ctx.deps.search.execute, query).await {
Ok(results) => axum::Json(SearchResponse {
movies: PaginatedMovieHits {
items: results
.movies
.items
.iter()
.map(|h| MovieSearchHitDto {
movie_id: h.movie_id.value(),
title: h.title.clone(),
release_year: h.release_year,
director: h.director.clone(),
poster_path: h.poster_path.clone(),
genres: h.genres.clone(),
})
.collect(),
total_count: results.movies.total_count,
limit: results.movies.limit,
offset: results.movies.offset,
},
people: PaginatedPersonHits {
items: results
.people
.items
.iter()
.map(|h| PersonSearchHitDto {
person_id: h.person_id.value(),
name: h.name.clone(),
known_for_department: h.known_for_department.clone(),
profile_path: h.profile_path.clone(),
known_for_titles: h.known_for_titles.clone(),
})
.collect(),
total_count: results.people.total_count,
limit: results.people.limit,
offset: results.people.offset,
},
})
.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[utoipa::path(
get, path = "/api/v1/people/{id}",
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 200, body = api_types::search::PersonDto),
(status = 404, description = "Person not found"),
),
tag = "search",
)]
pub async fn get_person_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> impl IntoResponse {
match get_person::execute(
&state.app_ctx.deps.person.get_person,
PersonId::from_uuid(id),
)
.await
{
Ok(Some(person)) => {
axum::Json(crate::mappers::search::person_to_dto(&person)).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[utoipa::path(
get, path = "/api/v1/people/{id}/credits",
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 200, body = api_types::search::PersonCreditsDto),
(status = 404, description = "Person not found"),
),
tag = "search",
)]
pub async fn get_person_credits_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> impl IntoResponse {
match get_person_credits::execute(
&state.app_ctx.deps.person.get_person,
PersonId::from_uuid(id),
)
.await
{
Ok(credits) => axum::Json(PersonCreditsDto {
person: crate::mappers::search::person_to_dto(&credits.person),
cast: credits
.cast
.iter()
.map(|c| CastCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
character: c.character.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
crew: credits
.crew
.iter()
.map(|c| CrewCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
job: c.job.clone(),
department: c.department.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
})
.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
pub async fn post_reindex_search(
State(state): State<AppState>,
_admin: crate::extractors::AdminApiUser,
) -> impl IntoResponse {
let event = domain::events::DomainEvent::SearchReindexRequested;
match state.app_ctx.services.event_publisher.publish(&event).await {
Ok(()) => StatusCode::ACCEPTED,
Err(e) => {
tracing::error!("failed to publish reindex event: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_tag(Path(tag): Path<String>) -> impl IntoResponse {
if tag.eq_ignore_ascii_case("moviesdiary") {
Redirect::temporary("/")
} else {
Redirect::temporary(&format!("/?search={}", tag))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
use super::*;
use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId};
fn local_actor(uid: &UserId) -> SocialActor {
SocialActor {
identity: SocialIdentity::Local(uid.clone()),
handle: "@gabriel@md.example".into(),
display_name: None,
avatar_url: None,
}
}
#[test]
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let dto = social_actor_to_dto(local_actor(&uid), &instance);
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
assert_eq!(
instance.identify(&dto.url),
SocialIdentity::Local(uid),
"a local actor's dto url must parse back as Local"
);
}
#[cfg(feature = "federation")]
#[test]
fn local_blocked_dto_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance);
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid));
}
#[test]
fn local_template_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let data = social_actor_to_template(local_actor(&uid), &instance);
assert!(!data.url.starts_with("local:"), "url was {}", data.url);
assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid));
}
#[test]
fn remote_actor_dto_url_is_passed_through_unchanged() {
let instance = InstanceIdentity::new("https://md.example");
let actor = SocialActor {
identity: SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
},
handle: "@bob@other.example".into(),
display_name: None,
avatar_url: None,
};
let dto = social_actor_to_dto(actor, &instance);
assert_eq!(dto.url, "https://other.example/users/bob");
}
#[test]
fn local_actor_dto_carries_user_id_and_avatar() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let mut actor = local_actor(&uid);
actor.avatar_url = Some("https://md.example/images/a.webp".into());
let dto = social_actor_to_dto(actor, &instance);
assert_eq!(
dto.user_id,
Some(uid.value()),
"local actors must carry user_id for internal links"
);
assert_eq!(
dto.avatar_url.as_deref(),
Some("https://md.example/images/a.webp")
);
}
#[test]
fn remote_actor_dto_has_no_user_id() {
let instance = InstanceIdentity::new("https://md.example");
let actor = SocialActor {
identity: SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
},
handle: "@bob@other.example".into(),
display_name: None,
avatar_url: None,
};
assert_eq!(social_actor_to_dto(actor, &instance).user_id, None);
}

View File

@@ -0,0 +1,869 @@
use std::str::FromStr;
use axum::{
Json,
extract::{Extension, Multipart, Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use uuid::Uuid;
use application::users::{
get_federated_profile, get_federated_profile_stats, get_local_profile,
get_profile_settings as get_profile_settings_uc, get_users,
queries::{GetUserProfileQuery, GetUsersQuery},
resolve_username_to_id, update_profile, update_profile_fields,
};
use domain::{errors::DomainError, value_objects::Username};
use crate::html::templates::{
EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate,
RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, bar_height_px,
build_heatmap, build_page_items,
};
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
render::render_page,
state::AppState,
};
use api_types::{
ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
UserTrendsDto, UsersResponse, WatchMediumStatDto,
};
use super::goals::goal_with_progress_to_dto;
use super::helpers::build_page_context;
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/profile",
responses(
(status = 200, body = ProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "User not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<ProfileResponse>, ApiError> {
let profile = application::users::get_current_profile::execute(
&state.app_ctx.deps.users.get_current_profile,
application::users::queries::GetCurrentProfileQuery {
user_id: user_id.value(),
},
)
.await?;
let instance = &state.app_ctx.instance;
Ok(Json(ProfileResponse {
profile: api_types::UserProfileBase {
username: profile.username,
display_name: profile.display_name,
bio: profile.bio,
avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)),
banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)),
},
also_known_as: profile.also_known_as,
fields: profile
.fields
.into_iter()
.map(|f| api_types::ProfileFieldDto {
name: f.name,
value: f.value,
})
.collect(),
role: profile.role,
}))
}
#[utoipa::path(
put, path = "/api/v1/profile",
responses(
(status = 204, description = "Profile updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
multipart: Multipart,
) -> impl IntoResponse {
let data = super::helpers::parse_profile_multipart(multipart).await;
let cmd = application::users::commands::UpdateProfileCommand {
user_id: user_id.value(),
display_name: data.display_name,
bio: data.bio,
avatar_bytes: data.avatar_bytes,
avatar_content_type: data.avatar_content_type,
banner_bytes: data.banner_bytes,
banner_content_type: data.banner_content_type,
also_known_as: data.also_known_as,
};
match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[utoipa::path(
put, path = "/api/v1/profile/fields",
request_body = api_types::UpdateProfileFieldsRequest,
responses(
(status = 204, description = "Profile fields updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_fields_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<serde_json::Value>,
) -> impl IntoResponse {
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
Some(arr) => arr.clone(),
None => return StatusCode::BAD_REQUEST.into_response(),
};
let fields: Vec<domain::models::ProfileField> = raw_fields
.iter()
.filter_map(|f| {
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
Some(domain::models::ProfileField { name, value })
})
.collect();
let cmd = application::users::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(),
fields,
};
match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[utoipa::path(
get, path = "/api/v1/users",
responses((status = 200, body = UsersResponse)),
)]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let result =
get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?;
Ok(Json(UsersResponse {
users: result
.users
.iter()
.map(|u| UserSummaryDto {
id: u.user_id.value(),
email: u.email().to_string(),
username: u.username().to_string(),
display_name: u.display_name().map(String::from),
total_movies: u.total_movies,
avg_rating: u.avg_rating,
})
.collect(),
}))
}
#[utoipa::path(
get, path = "/api/v1/users/{id}",
params(
("id" = Uuid, Path, description = "User ID"),
UserProfileQueryParams,
),
responses(
(status = 200, body = UserProfileResponse),
(status = 404, description = "User not found"),
)
)]
pub async fn get_user_profile(
State(state): State<AppState>,
AuthenticatedUser(viewer_id): AuthenticatedUser,
Path(user_id): Path<Uuid>,
Query(params): Query<UserProfileQueryParams>,
) -> impl IntoResponse {
let view_str = params.view.as_deref().unwrap_or("recent");
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
Ok(v) => v,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
let profile = match get_local_profile::execute(
&state.app_ctx.deps.users.get_local_profile,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: viewer_id.value() == user_id,
include_remote: false,
},
)
.await
{
Ok(p) => p,
Err(DomainError::NotFound(_)) => {
if let Ok(Some(fed)) = get_federated_profile::execute(
&state.app_ctx.deps.users.get_federated_profile,
user_id,
)
.await
{
return build_federated_profile_response(
&state,
user_id,
fed,
profile_view,
&params,
)
.await;
}
return StatusCode::NOT_FOUND.into_response();
}
Err(e) => return crate::errors::domain_error_response(e),
};
let view_data = if let Some(p) = profile.entries {
Some(api_types::ProfileViewData::Entries {
entries: DiaryResponse {
items: p
.items
.iter()
.map(crate::mappers::movies::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
},
})
} else if let Some(h) = profile.history {
Some(api_types::ProfileViewData::History {
history: application::users::group_by_month(h)
.into_iter()
.map(|m| MonthActivityDto {
year_month: m.year_month,
month_label: m.month_label,
count: m.count,
entries: m
.entries
.iter()
.map(crate::mappers::movies::entry_to_dto)
.collect(),
})
.collect(),
})
} else {
profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
};
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username: profile.identity.username.clone(),
avatar_url: profile.identity.avatar_url.clone(),
banner_url: profile.identity.banner_url.clone(),
display_name: profile.identity.display_name.clone(),
bio: profile.identity.bio.clone(),
},
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
avg_rating: profile.stats.avg_rating,
favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month,
},
following_count: profile.following_count,
followers_count: profile.followers_count,
view_data,
goals: {
let goals_list = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
.unwrap_or_default();
if goals_list.is_empty() {
None
} else {
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
}
},
is_federated: false,
handle: Some(profile.identity.handle.clone()),
actor_url: Some(profile.identity.actor_url.clone()),
})
.into_response()
}
async fn build_federated_profile_response(
state: &AppState,
user_id: Uuid,
fed: domain::models::FederatedProfile,
profile_view: application::users::queries::ProfileView,
params: &UserProfileQueryParams,
) -> axum::response::Response {
let profile = match get_federated_profile_stats::execute(
&state.app_ctx.deps.users.get_federated_profile_stats,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: false,
include_remote: true,
},
)
.await
{
Ok(p) => p,
Err(e) => return crate::errors::domain_error_response(e),
};
let view_data = if let Some(p) = profile.entries {
Some(api_types::ProfileViewData::Entries {
entries: DiaryResponse {
items: p
.items
.iter()
.map(crate::mappers::movies::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
},
})
} else {
profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
};
let username = fed
.display_name
.clone()
.unwrap_or_else(|| fed.handle.clone());
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username,
avatar_url: fed.avatar_url,
banner_url: fed.banner_url,
display_name: fed.display_name,
bio: fed.bio,
},
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
avg_rating: profile.stats.avg_rating,
favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month,
},
following_count: 0,
followers_count: 0,
view_data,
goals: None,
is_federated: true,
handle: Some(fed.handle),
actor_url: Some(fed.actor_url),
})
.into_response()
}
fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto {
UserTrendsDto {
monthly_ratings: t
.monthly_ratings
.into_iter()
.map(|r| MonthlyRatingDto {
year_month: r.year_month,
month_label: r.month_label,
avg_rating: r.avg_rating,
count: r.count,
})
.collect(),
top_directors: t
.top_directors
.into_iter()
.map(|d| DirectorStatDto {
director: d.director,
count: d.count,
})
.collect(),
max_director_count: t.max_director_count,
top_genres: t
.top_genres
.into_iter()
.map(|g| GenreStatDto {
genre: g.genre,
count: g.count,
})
.collect(),
rating_distribution: t.rating_distribution,
watch_medium_distribution: t
.watch_medium_distribution
.into_iter()
.map(|m| WatchMediumStatDto {
medium: m.medium,
count: m.count,
})
.collect(),
top_actors: t
.top_actors
.into_iter()
.map(|a| ActorStatDto {
name: a.name,
profile_path: a.profile_path,
movie_count: a.movie_count,
score: a.score,
})
.collect(),
}
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_users_list(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, user_id, csrf.0).await;
ctx.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
match application::users::get_users::execute(
&state.app_ctx.deps.users.get_users_list,
application::users::queries::GetUsersQuery,
)
.await
{
Ok(result) => {
let users: Vec<UserSummaryView> = result
.users
.iter()
.map(crate::mappers::users::user_summary_view)
.collect();
let remote_actors: Vec<RemoteActorDisplay> = result
.remote_actors
.iter()
.map(crate::mappers::users::remote_actor_display)
.collect();
render_page(UsersTemplate {
users,
ctx: &ctx,
remote_actors,
})
.into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}
pub async fn get_user_by_username(
State(state): State<AppState>,
Path(username): Path<String>,
) -> impl IntoResponse {
let uname = match Username::new(username) {
Ok(u) => u,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
match resolve_username_to_id::execute(&state.app_ctx.deps.users.resolve_username, &uname).await
{
Ok(Some(uid)) => {
axum::response::Redirect::permanent(&format!("/users/{}", uid.value())).into_response()
}
_ => StatusCode::NOT_FOUND.into_response(),
}
}
// ── Profile helpers (private) ───────────────────────────────────────────────
struct PaginationInfo {
offset: u32,
has_more: bool,
limit: u32,
page_items: Vec<crate::html::templates::PageItem>,
}
fn compute_pagination(
entries: Option<&domain::models::collections::Paginated<domain::models::DiaryEntry>>,
) -> PaginationInfo {
let (offset, has_more, limit) = entries
.map(|e| {
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
(e.offset, has_more, e.limit)
})
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
let total = entries.map(|e| e.total_count as u32).unwrap_or(0);
let total_pages = total
.saturating_add(limit.saturating_sub(1))
.checked_div(limit)
.unwrap_or(1);
let current_page = offset.checked_div(limit).unwrap_or(0);
let page_items = build_page_items(total_pages, current_page);
PaginationInfo {
offset,
has_more,
limit,
page_items,
}
}
struct StatsDisplay {
avg_rating: String,
favorite_director: String,
most_active_month: String,
}
fn build_stats_display(stats: &domain::models::UserStats) -> StatsDisplay {
StatsDisplay {
avg_rating: stats
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "\u{2014}".to_string()),
favorite_director: stats
.favorite_director
.clone()
.unwrap_or_else(|| "\u{2014}".to_string()),
most_active_month: stats
.most_active_month
.clone()
.unwrap_or_else(|| "\u{2014}".to_string()),
}
}
fn build_monthly_rating_rows<'a>(
trends: Option<&'a domain::models::UserTrends>,
) -> Vec<MonthlyRatingRow<'a>> {
trends
.map(|t| {
t.monthly_ratings
.iter()
.map(|r| MonthlyRatingRow {
rating: r,
bar_height_px: bar_height_px(r.avg_rating),
})
.collect()
})
.unwrap_or_default()
}
async fn fetch_profile_goals(
state: &AppState,
user_id: Uuid,
) -> Vec<crate::html::templates::GoalViewData> {
let goals_list = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
.unwrap_or_default();
goals_list
.iter()
.map(|g| crate::html::templates::GoalViewData {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage().round(),
is_complete: g.is_complete(),
})
.collect()
}
// ── Handler ─────────────────────────────────────────────────────────────────
pub async fn get_user_profile_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<crate::forms::ProfileQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
// Content negotiation: AP clients request application/activity+json
#[cfg(feature = "federation")]
{
let accept = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
return match state
.app_ctx
.ap_document
.actor_json(&profile_user_uuid.to_string())
.await
{
Ok(json) => (
[(
axum::http::header::CONTENT_TYPE,
"application/activity+json",
)],
json,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
}
#[cfg(not(feature = "federation"))]
let _ = &headers;
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let view_str = params.view.as_deref().unwrap_or("recent");
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
Ok(v) => v,
Err(_) => {
return (
axum::http::StatusCode::BAD_REQUEST,
"invalid view parameter",
)
.into_response();
}
};
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
"rating" => "rating",
"rating_asc" => "rating_asc",
_ => "date",
};
let is_own_profile = user_id
.as_ref()
.map(|u| u.value() == profile_user_uuid)
.unwrap_or(false);
let query = application::users::queries::GetUserProfileQuery {
user_id: profile_user_uuid,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: sort_by_str.parse().unwrap_or_default(),
search: if params.search.is_empty() {
None
} else {
Some(params.search.clone())
},
is_own_profile,
include_remote: false,
};
match get_local_profile::execute(&state.app_ctx.deps.users.get_local_profile, query).await {
Ok(profile) => {
ctx.page_title = format!("{}'s Diary — Movies Diary", profile.identity.username);
ctx.canonical_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let pag = compute_pagination(profile.entries.as_ref());
if !is_own_profile {
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
}
let display_name = profile
.identity
.email
.split('@')
.next()
.unwrap_or("?")
.to_string();
let stats_disp = build_stats_display(&profile.stats);
let history = profile.history.map(application::users::group_by_month);
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
let monthly_rating_rows = build_monthly_rating_rows(profile.trends.as_ref());
let pending_followers: Vec<RemoteActorData> = profile
.pending_followers
.iter()
.map(crate::mappers::users::pending_follower_data)
.collect();
if params.embed {
let profile_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let response = render_page(EmbedProfileTemplate {
profile_display_name: display_name,
profile_user_id: profile_user_uuid,
profile_url,
stats: &profile.stats,
avg_rating_display: stats_disp.avg_rating,
favorite_director_display: stats_disp.favorite_director,
most_active_month_display: stats_disp.most_active_month,
view: profile_view.as_str(),
entries: profile.entries.as_ref(),
current_offset: pag.offset,
has_more: pag.has_more,
limit: pag.limit,
history: history.as_ref(),
trends: profile.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items: pag.page_items,
sort_by: sort_by_str.to_string(),
});
let mut resp = response.into_response();
resp.headers_mut().remove("x-frame-options");
resp
} else {
let goals = fetch_profile_goals(&state, profile_user_uuid).await;
render_page(ProfileTemplate {
ctx: &ctx,
profile_display_name: display_name,
profile_user_id: profile_user_uuid,
stats: &profile.stats,
avg_rating_display: stats_disp.avg_rating,
favorite_director_display: stats_disp.favorite_director,
most_active_month_display: stats_disp.most_active_month,
view: profile_view.as_str(),
entries: profile.entries.as_ref(),
current_offset: pag.offset,
has_more: pag.has_more,
limit: pag.limit,
history: history.as_ref(),
trends: profile.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items: pag.page_items,
is_own_profile,
error: params.error,
following_count: profile.following_count,
followers_count: profile.followers_count,
pending_followers,
sort_by: sort_by_str.to_string(),
search: params.search.clone(),
goals,
})
.into_response()
}
}
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[derive(serde::Deserialize, Default)]
pub struct SavedQuery {
pub saved: Option<String>,
}
pub async fn get_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<SavedQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Profile Settings — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
let settings = match get_profile_settings_uc::execute(
&state.app_ctx.deps.users.get_profile_settings,
user_id.value(),
)
.await
{
Ok(s) => s,
Err(DomainError::NotFound(_)) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return crate::errors::domain_error_response(e),
};
let saved = params.saved.as_deref() == Some("1");
render_page(ProfileSettingsTemplate {
ctx: &ctx,
bio: settings.bio.as_deref(),
avatar_url: settings.avatar_url.as_deref(),
banner_url: settings.banner_url.as_deref(),
also_known_as: settings.also_known_as.as_deref(),
profile_fields: &settings.fields,
saved,
embed_url: format!(
"{}/users/{}?embed=true",
state.app_ctx.config.base_url,
user_id.value()
),
})
.into_response()
}
pub async fn post_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
multipart: Multipart,
) -> impl IntoResponse {
let data = super::helpers::parse_profile_multipart(multipart).await;
let cmd = application::users::commands::UpdateProfileCommand {
user_id: user_id.value(),
display_name: data.display_name,
bio: data.bio,
avatar_bytes: data.avatar_bytes,
avatar_content_type: data.avatar_content_type,
banner_bytes: data.banner_bytes,
banner_content_type: data.banner_content_type,
also_known_as: data.also_known_as,
};
if let Err(e) = update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
tracing::error!("update_profile error: {:?}", e);
return axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
super::helpers::encode_domain_error(&e)
))
.into_response();
}
let fields: Vec<domain::models::ProfileField> = (0..4)
.filter_map(|i| {
data.profile_field_names
.get(&i)
.map(|name| domain::models::ProfileField {
name: name.clone(),
value: data
.profile_field_values
.get(&i)
.cloned()
.unwrap_or_default(),
})
})
.collect();
let fields_cmd = application::users::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(),
fields,
};
if let Err(e) =
update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, fields_cmd)
.await
{
tracing::error!("update_profile_fields error: {:?}", e);
return axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
super::helpers::encode_domain_error(&e)
))
.into_response();
}
axum::response::Redirect::to("/settings/profile?saved=1").into_response()
}

View File

@@ -0,0 +1,312 @@
use axum::{
Form, Json,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use uuid::Uuid;
use application::{
diary::commands::MovieInput,
watchlist::{
add as add_to_watchlist,
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
get as get_watchlist, get_watchlist_for_owner, is_on as is_on_watchlist,
queries::{GetWatchlistQuery, IsOnWatchlistQuery},
remove as remove_from_watchlist,
},
};
use domain::errors::DomainError;
use crate::html::templates::WatchlistTemplate;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
render::render_page,
state::AppState,
};
use api_types::{
AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse,
WatchlistStatusResponse,
};
use super::helpers::{build_page_context, encode_error};
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/watchlist",
params(
("limit" = Option<u32>, Query, description = "Max results"),
("offset" = Option<u32>, Query, description = "Offset"),
),
responses(
(status = 200, body = WatchlistResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_handler(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, ApiError> {
let page = get_watchlist::execute(
&state.app_ctx.deps.watchlist.get_watchlist,
GetWatchlistQuery {
user_id: user.0.value(),
limit: params.limit,
offset: params.offset,
},
)
.await?;
Ok(Json(WatchlistResponse {
items: page
.items
.into_iter()
.map(|w| WatchlistEntryDto {
id: w.entry.id.value(),
movie: crate::mappers::movies::movie_to_dto(&w.movie),
added_at: w.entry.added_at.to_string(),
})
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
post, path = "/api/v1/watchlist",
request_body = AddToWatchlistRequest,
responses(
(status = 201, description = "Added to watchlist"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Movie not found"),
),
security(("bearer_auth" = []))
)]
pub async fn post_watchlist_add(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> {
add_to_watchlist::execute(
&state.app_ctx.deps.watchlist.add,
AddToWatchlistCommand {
user_id: user.0.value(),
input: MovieInput {
movie_id: req.movie_id,
external_metadata_id: req.external_metadata_id,
manual_title: req.manual_title,
manual_release_year: req.manual_release_year,
manual_director: None,
},
},
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
delete, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 204, description = "Removed from watchlist"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not on watchlist"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_watchlist_entry(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute(
&state.app_ctx.deps.watchlist.remove_from_watchlist,
RemoveFromWatchlistCommand {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = WatchlistStatusResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_status(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
&state.app_ctx.deps.watchlist.is_on_watchlist,
IsOnWatchlistQuery {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_watchlist_page(
OptionalCookieUser(viewer_id): OptionalCookieUser,
State(state): State<AppState>,
Path(owner_id): Path<uuid::Uuid>,
Query(params): Query<crate::forms::WatchlistQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
let view = match get_watchlist_for_owner::execute(
&state.app_ctx.deps.watchlist.get_watchlist_for_owner,
owner_id,
params.limit.or(Some(20)),
params.offset.or(Some(0)),
)
.await
{
Ok(view) => view,
Err(e) => return crate::errors::domain_error_response(e),
};
let result = match view {
get_watchlist_for_owner::WatchlistView::Local(page) => {
crate::mappers::watchlist::build_watchlist_page(page, is_owner)
}
get_watchlist_for_owner::WatchlistView::Remote(entries) => {
crate::mappers::watchlist::build_remote_watchlist_page(entries)
}
};
render_page(WatchlistTemplate {
ctx: &ctx,
owner_id,
display_entries: &result.display_entries,
current_offset: result.current_offset,
has_more: result.has_more,
limit: result.limit,
is_owner,
error: params.error,
})
.into_response()
}
pub async fn post_watchlist_add_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::WatchlistAddForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let redirect_base = form
.redirect_after
.as_deref()
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or("/")
.to_string();
let input = if let Some(id) = form.movie_id {
MovieInput {
movie_id: Some(id),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
}
} else {
let query = form.query.as_deref().unwrap_or("").trim().to_string();
let is_external_id = query.starts_with("tmdb:")
|| (query.starts_with("tt")
&& query.len() > 2
&& query[2..].chars().all(|c| c.is_ascii_digit()));
if is_external_id {
MovieInput {
movie_id: None,
external_metadata_id: Some(query),
manual_title: None,
manual_release_year: None,
manual_director: None,
}
} else {
MovieInput {
movie_id: None,
external_metadata_id: None,
manual_title: if query.is_empty() { None } else { Some(query) },
manual_release_year: form.year,
manual_director: None,
}
}
};
match add_to_watchlist::execute(
&state.app_ctx.deps.watchlist.add,
AddToWatchlistCommand {
user_id: user_id.value(),
input,
},
)
.await
{
Ok(()) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::ValidationError(msg)) => {
let sep = if redirect_base.contains('?') {
'&'
} else {
'?'
};
let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg));
Redirect::to(&url).into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}
pub async fn post_watchlist_remove_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(movie_id): Path<uuid::Uuid>,
Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match remove_from_watchlist::execute(
&state.app_ctx.deps.watchlist.remove_from_watchlist,
RemoveFromWatchlistCommand {
user_id: user_id.value(),
movie_id,
},
)
.await
{
Ok(()) | Err(DomainError::NotFound(_)) => {
let redirect_url = form
.redirect_after
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or_else(|| "/".to_string());
Redirect::to(&redirect_url).into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}

View File

@@ -0,0 +1,339 @@
use axum::{
Json,
extract::{Multipart, Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use uuid::Uuid;
use api_types::{
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto,
};
use application::integrations::{
commands::{
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
},
confirm as confirm_watch_events, dismiss as dismiss_watch_events,
generate_token as generate_webhook_token, get_queue as get_watch_queue,
get_tokens as get_webhook_tokens, ingest as ingest_watch_event,
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
revoke_token as revoke_webhook_token,
};
use domain::models::WatchEventSource;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|t| t.trim().to_string())
}
#[derive(serde::Deserialize, Default)]
pub struct WebhookTokenQuery {
pub token: Option<String>,
}
fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option<String> {
extract_bearer_token(headers).or_else(|| query.token.clone())
}
// ── Webhook ingestion (no JWT, uses webhook bearer token) ─────────────────────
#[utoipa::path(
post, path = "/api/v1/webhooks/jellyfin",
request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"),
(status = 401, description = "Invalid or missing webhook token"),
),
security(("bearer_auth" = []))
)]
pub async fn post_jellyfin_webhook(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
};
let cmd = IngestWatchEventCommand {
token,
raw_payload: body.to_vec(),
source: WatchEventSource::Jellyfin,
};
run_ingest(
&state,
cmd,
&*state.app_ctx.deps.integrations.jellyfin_parser,
)
.await
}
// ── Plex webhook (multipart form data with `payload` JSON field) ──────────────
#[utoipa::path(
post, path = "/api/v1/webhooks/plex",
request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"),
(status = 401, description = "Invalid or missing webhook token"),
),
security(("bearer_auth" = []))
)]
pub async fn post_plex_webhook(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
mut multipart: Multipart,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
};
let mut payload_bytes: Option<Vec<u8>> = None;
while let Ok(Some(field)) = multipart.next_field().await {
if field.name() == Some("payload")
&& let Ok(bytes) = field.bytes().await
{
payload_bytes = Some(bytes.to_vec());
break;
}
}
let raw_payload = match payload_bytes {
Some(b) => b,
None => return StatusCode::BAD_REQUEST,
};
let cmd = IngestWatchEventCommand {
token,
raw_payload,
source: WatchEventSource::Plex,
};
run_ingest(&state, cmd, &*state.app_ctx.deps.integrations.plex_parser).await
}
async fn run_ingest(
state: &AppState,
cmd: IngestWatchEventCommand,
parser: &dyn domain::ports::MediaServerParser,
) -> StatusCode {
match ingest_watch_event::execute(
&state.app_ctx.deps.integrations.ingest_watch_event,
cmd,
parser,
)
.await
{
Ok(()) => StatusCode::OK,
Err(e) => crate::errors::domain_error_status(&e),
}
}
// ── Token management (JWT-authenticated) ──────────────────────────────────────
#[utoipa::path(
post, path = "/api/v1/settings/webhook-tokens",
request_body = GenerateTokenRequest,
responses(
(status = 200, body = GenerateTokenResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn post_generate_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<GenerateTokenRequest>,
) -> Result<Json<GenerateTokenResponse>, ApiError> {
let provider: WatchEventSource = req
.provider
.parse()
.map_err(|e: String| domain::errors::DomainError::ValidationError(e))?;
let cmd = GenerateWebhookTokenCommand {
user_id: user.0.value(),
provider: provider.clone(),
label: req.label,
};
let result = generate_webhook_token::execute(
&state.app_ctx.deps.integrations.generate_webhook_token,
cmd,
)
.await?;
let base_url = &state.app_ctx.config.base_url;
let webhook_url = format!("{base_url}/api/v1/webhooks/{provider}");
Ok(Json(GenerateTokenResponse {
id: result.token.id().value().to_string(),
token: result.token_plaintext,
webhook_url,
}))
}
#[utoipa::path(
get, path = "/api/v1/settings/webhook-tokens",
responses(
(status = 200, body = Vec<WebhookTokenDto>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_webhook_tokens(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<WebhookTokenDto>>, ApiError> {
let query = GetWebhookTokensQuery {
user_id: user.0.value(),
};
let tokens =
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
.await?;
let dtos = tokens
.into_iter()
.map(|t| WebhookTokenDto {
id: t.id().value().to_string(),
provider: t.provider().to_string(),
label: t.label().map(String::from),
created_at: t.created_at().to_string(),
last_used_at: t.last_used_at().map(|d| d.to_string()),
})
.collect();
Ok(Json(dtos))
}
#[utoipa::path(
delete, path = "/api/v1/settings/webhook-tokens/{id}",
params(("id" = Uuid, Path, description = "Webhook token ID")),
responses(
(status = 204, description = "Token revoked"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Token not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let cmd = RevokeWebhookTokenCommand {
user_id: user.0.value(),
token_id: id,
};
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Watch queue (JWT-authenticated) ───────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/watch-queue",
responses(
(status = 200, body = Vec<WatchQueueEntryDto>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watch_queue(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<WatchQueueEntryDto>>, ApiError> {
let query = GetWatchQueueQuery {
user_id: user.0.value(),
};
let events =
get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query).await?;
let dtos = events
.into_iter()
.map(|e| WatchQueueEntryDto {
id: e.id().value().to_string(),
title: e.title().to_string(),
year: e.year(),
movie_id: e.movie_id().map(|m| m.value().to_string()),
source: e.source().to_string(),
watched_at: e.watched_at().to_string(),
})
.collect();
Ok(Json(dtos))
}
#[utoipa::path(
post, path = "/api/v1/watch-queue/confirm",
request_body = ConfirmWatchRequest,
responses(
(status = 200, body = ConfirmWatchResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — not your watch event"),
),
security(("bearer_auth" = []))
)]
pub async fn post_confirm_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<ConfirmWatchRequest>,
) -> Result<Json<ConfirmWatchResponse>, ApiError> {
let cmd = ConfirmWatchEventsCommand {
user_id: user.0.value(),
confirmations: req
.confirmations
.into_iter()
.map(|c| WatchEventConfirmation {
watch_event_id: c.watch_event_id,
rating: c.rating,
comment: c.comment,
})
.collect(),
};
let confirmed =
confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
.await?;
Ok(Json(ConfirmWatchResponse { confirmed }))
}
#[utoipa::path(
post, path = "/api/v1/watch-queue/dismiss",
request_body = DismissWatchRequest,
responses(
(status = 200, body = DismissWatchResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — not your watch event"),
),
security(("bearer_auth" = []))
)]
pub async fn post_dismiss_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<DismissWatchRequest>,
) -> Result<Json<DismissWatchResponse>, ApiError> {
let cmd = DismissWatchEventsCommand {
user_id: user.0.value(),
event_ids: req.event_ids,
};
let dismissed =
dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
.await?;
Ok(Json(DismissWatchResponse { dismissed }))
}

View File

@@ -0,0 +1,275 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::NaiveDate;
use uuid::Uuid;
use application::wrapup::{
commands::RequestWrapUpCommand,
delete as delete_wrapup, generate, get_ready_report, get_wrapup,
list_wrapups::{self, ListWrapUpsQuery},
};
use domain::errors::DomainError;
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus};
use domain::value_objects::WrapUpId;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AdminApiUser, AuthenticatedUser, OptionalCookieUser},
render::render_page,
state::AppState,
};
use api_types::wrapup::{
GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse,
};
fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse {
WrapUpStatusResponse {
id: r.id.value().to_string(),
user_id: r.user_id.map(|u| u.to_string()),
status: format!("{:?}", r.status),
start_date: r.start_date.to_string(),
end_date: r.end_date.to_string(),
created_at: r.created_at.to_string(),
completed_at: r.completed_at.map(|t| t.to_string()),
error_message: r.error_message.clone(),
}
}
#[utoipa::path(
post, path = "/api/v1/wrapups/generate",
request_body = GenerateWrapUpRequest,
responses(
(status = 200, body = WrapUpGeneratedResponse),
(status = 400, description = "Invalid date format"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — admin only"),
),
security(("bearer_auth" = []))
)]
pub async fn post_generate(
State(state): State<AppState>,
_admin: AdminApiUser,
Json(req): Json<GenerateWrapUpRequest>,
) -> Result<Json<WrapUpGeneratedResponse>, ApiError> {
let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d")
.map_err(|_| DomainError::ValidationError("invalid start_date".into()))?;
let end = NaiveDate::parse_from_str(&req.end_date, "%Y-%m-%d")
.map_err(|_| DomainError::ValidationError("invalid end_date".into()))?;
let user_id = req.user_id;
let cmd = RequestWrapUpCommand {
user_id,
start_date: start,
end_date: end,
};
let id = generate::execute(&state.app_ctx.deps.wrapup.generate, cmd).await?;
Ok(Json(WrapUpGeneratedResponse {
id: id.value().to_string(),
}))
}
#[utoipa::path(
get, path = "/api/v1/wrapups",
responses(
(status = 200, body = WrapUpListResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_list(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<WrapUpListResponse>, ApiError> {
let records = list_wrapups::execute(
&state.app_ctx.deps.wrapup.list_wrapups,
ListWrapUpsQuery {
user_id: Some(user.0.value()),
},
)
.await?;
Ok(Json(WrapUpListResponse {
items: records.iter().map(record_to_dto).collect(),
}))
}
#[utoipa::path(
get, path = "/api/v1/wrapups/{id}",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 200, body = WrapUpStatusResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_status(
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<Json<WrapUpStatusResponse>, ApiError> {
let record = get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup,
WrapUpId::from_uuid(id),
)
.await?
.ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?;
Ok(Json(record_to_dto(&record)))
}
#[utoipa::path(
get, path = "/api/v1/wrapups/{id}/report",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 200, description = "Report JSON", content_type = "application/json"),
(status = 202, description = "Still generating"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_report(
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup,
WrapUpId::from_uuid(id),
)
.await
{
Ok(Some(record)) if record.status == WrapUpStatus::Ready => match record.report {
Some(ref report) => {
let json = serde_json::to_string(report).unwrap_or_default();
(StatusCode::OK, [("content-type", "application/json")], json).into_response()
}
None => StatusCode::NOT_FOUND.into_response(),
},
Ok(Some(_)) => StatusCode::ACCEPTED.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[utoipa::path(
delete, path = "/api/v1/wrapups/{id}",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — admin only"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_wrapup_handler(
State(state): State<AppState>,
_admin: AdminApiUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_wrapup::execute(
&state.app_ctx.deps.wrapup.delete_wrapup,
WrapUpId::from_uuid(id),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── HTML handlers ───────────────────────────────────────────────────────────
fn format_watch_time(minutes: u32) -> String {
let h = minutes / 60;
let m = minutes % 60;
if h > 0 && m > 0 {
format!("{}h {}m", h, m)
} else if h > 0 {
format!("{}h", h)
} else {
format!("{}m", m)
}
}
fn render_wrapup(
report: &WrapUpReport,
year: i32,
ctx: &api_types::HtmlPageContext,
) -> axum::response::Response {
let rating_max = report
.rating_distribution
.iter()
.copied()
.max()
.unwrap_or(1)
.max(1);
let rating_pcts: [f64; 5] =
std::array::from_fn(|i| report.rating_distribution[i] as f64 / rating_max as f64 * 100.0);
let genre_max = report
.top_genres
.first()
.map(|g| g.count)
.unwrap_or(1)
.max(1);
let genre_pcts: Vec<f64> = report
.top_genres
.iter()
.take(8)
.map(|g| g.count as f64 / genre_max as f64 * 100.0)
.collect();
let tmpl = crate::html::templates::WrapUpPageTemplate {
ctx,
report,
year_label: year.to_string(),
watch_time_display: format_watch_time(report.total_watch_time_minutes),
rating_max,
genre_max,
rating_pcts,
genre_pcts,
};
render_page(tmpl)
}
pub async fn get_user_wrapup_html(
OptionalCookieUser(viewer): OptionalCookieUser,
State(state): State<AppState>,
Path((user_id, year)): Path<(Uuid, i32)>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let scope = WrapUpScope::User(user_id);
let report =
match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year)
.await
{
Ok(report) => report,
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx)
}
pub async fn get_global_wrapup_html(
OptionalCookieUser(viewer): OptionalCookieUser,
State(state): State<AppState>,
Path(year): Path<i32>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let report = match get_ready_report::execute(
&state.app_ctx.deps.wrapup.get_ready_report,
WrapUpScope::Global,
year,
)
.await
{
Ok(report) => report,
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx)
}

View File

@@ -0,0 +1 @@
pub mod templates;

View File

@@ -0,0 +1,495 @@
pub use askama;
use askama::Template;
use api_types::HtmlPageContext;
use chrono::Datelike;
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,
collections::Paginated,
};
#[derive(Clone, Debug)]
pub struct WatchlistDisplayEntry {
pub poster_url: Option<String>,
pub movie_title: String,
pub release_year: u16,
pub movie_url: Option<String>,
pub added_at: String,
pub remove_url: Option<String>,
}
mod filters {
#[askama::filter_fn]
pub fn poster_src<T: std::fmt::Display>(
path: T,
_env: &dyn askama::Values,
) -> askama::Result<String> {
let p = path.to_string();
if p.starts_with("http://") || p.starts_with("https://") {
Ok(p)
} else {
Ok(format!("/images/{}", p))
}
}
}
pub struct PageItem {
pub number: u32,
pub is_current: bool,
pub is_ellipsis: bool,
}
pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
if total_pages <= 1 {
return vec![];
}
let mut set = std::collections::BTreeSet::new();
set.insert(0u32);
set.insert(total_pages - 1);
let start = current_page.saturating_sub(2);
let end = (current_page + 2).min(total_pages - 1);
for p in start..=end {
set.insert(p);
}
let pages: Vec<u32> = set.into_iter().collect();
let mut items = Vec::new();
for (i, &p) in pages.iter().enumerate() {
if i > 0 && p > pages[i - 1] + 1 {
items.push(PageItem {
number: 0,
is_current: false,
is_ellipsis: true,
});
}
items.push(PageItem {
number: p,
is_current: p == current_page,
is_ellipsis: false,
});
}
items
}
#[derive(Template)]
#[template(path = "diary.html")]
pub struct DiaryTemplate<'a> {
pub entries: &'a [DiaryEntry],
pub current_offset: u32,
pub limit: u32,
pub has_more: bool,
pub ctx: &'a HtmlPageContext,
pub page_items: Vec<PageItem>,
}
#[derive(Template)]
#[template(path = "login.html")]
pub struct LoginTemplate<'a> {
pub error: Option<&'a str>,
pub ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "register.html")]
pub struct RegisterTemplate<'a> {
pub error: Option<&'a str>,
pub ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "new_review.html")]
pub struct NewReviewTemplate<'a> {
pub error: Option<&'a str>,
pub ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "activity_feed.html")]
pub struct ActivityFeedTemplate<'a> {
pub entries: &'a [FeedEntry],
pub current_offset: u32,
pub limit: u32,
pub has_more: bool,
pub ctx: &'a HtmlPageContext,
pub page_items: Vec<PageItem>,
pub filter: String,
pub sort_by: String,
pub search: String,
}
#[derive(Template)]
#[template(path = "movie_detail.html")]
pub struct MovieDetailTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub movie: &'a domain::models::Movie,
pub stats: &'a domain::models::MovieStats,
pub profile: Option<&'a domain::models::MovieProfile>,
pub reviews: &'a [domain::models::FeedEntry],
pub on_watchlist: bool,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub histogram_max: u64,
}
#[derive(Template)]
#[template(path = "watchlist.html")]
pub struct WatchlistTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub owner_id: uuid::Uuid,
pub display_entries: &'a [WatchlistDisplayEntry],
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub is_owner: bool,
pub error: Option<String>,
}
impl<'a> ActivityFeedTemplate<'a> {
pub fn filter_qs(&self) -> String {
let mut parts = vec![
format!("filter={}", self.filter),
format!("sort_by={}", self.sort_by),
];
if !self.search.is_empty() {
let encoded = self
.search
.replace(' ', "+")
.replace('#', "%23")
.replace('&', "%26")
.replace('=', "%3D");
parts.push(format!("search={}", encoded));
}
format!("&{}", parts.join("&"))
}
}
pub struct RemoteActorDisplay {
pub handle: String,
pub display_name: String,
pub initial: char,
pub url: String,
}
pub struct UserSummaryView {
pub user_id: uuid::Uuid,
pub display_name: String,
pub initial: char,
pub avg_rating_display: String,
pub total_movies: i64,
pub avatar_url: Option<String>,
}
#[derive(Template)]
#[template(path = "users.html")]
pub struct UsersTemplate<'a> {
pub users: Vec<UserSummaryView>,
pub ctx: &'a HtmlPageContext,
pub remote_actors: Vec<RemoteActorDisplay>,
}
pub struct MonthlyRatingRow<'a> {
pub rating: &'a MonthlyRating,
pub bar_height_px: i64,
}
#[derive(Template)]
#[template(path = "profile.html")]
pub struct ProfileTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub profile_display_name: String,
pub profile_user_id: uuid::Uuid,
pub stats: &'a UserStats,
pub avg_rating_display: String,
pub favorite_director_display: String,
pub most_active_month_display: String,
pub view: &'a str,
pub entries: Option<&'a Paginated<DiaryEntry>>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub history: Option<&'a Vec<MonthActivity>>,
pub trends: Option<&'a UserTrends>,
pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
pub heatmap: Vec<HeatmapCell>,
pub page_items: Vec<PageItem>,
pub is_own_profile: bool,
pub error: Option<String>,
pub following_count: usize,
pub followers_count: usize,
pub pending_followers: Vec<RemoteActorData>,
pub sort_by: String,
pub search: String,
pub goals: Vec<GoalViewData>,
}
pub struct GoalViewData {
pub year: u16,
pub target_count: u32,
pub current_count: u32,
pub percentage: f64,
pub is_complete: bool,
}
impl<'a> ProfileTemplate<'a> {
pub fn filter_qs(&self) -> String {
let mut parts = vec![
format!("view={}", self.view),
format!("sort_by={}", self.sort_by),
];
if !self.search.is_empty() {
let encoded = self
.search
.replace(' ', "+")
.replace('#', "%23")
.replace('&', "%26")
.replace('=', "%3D");
parts.push(format!("search={}", encoded));
}
format!("&{}", parts.join("&"))
}
}
#[derive(Template)]
#[template(path = "embed_profile.html")]
pub struct EmbedProfileTemplate<'a> {
pub profile_display_name: String,
pub profile_user_id: uuid::Uuid,
pub profile_url: String,
pub stats: &'a UserStats,
pub avg_rating_display: String,
pub favorite_director_display: String,
pub most_active_month_display: String,
pub view: &'a str,
pub entries: Option<&'a Paginated<DiaryEntry>>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub history: Option<&'a Vec<MonthActivity>>,
pub trends: Option<&'a UserTrends>,
pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
pub heatmap: Vec<HeatmapCell>,
pub page_items: Vec<PageItem>,
pub sort_by: String,
}
impl<'a> EmbedProfileTemplate<'a> {
pub fn filter_qs(&self) -> String {
let parts = [
format!("view={}", self.view),
format!("sort_by={}", self.sort_by),
"embed=true".to_string(),
];
format!("&{}", parts.join("&"))
}
}
pub struct RemoteActorData {
pub handle: String,
pub display_name: Option<String>,
pub url: String,
pub avatar_url: Option<String>,
}
#[derive(Template)]
#[template(path = "following.html")]
pub struct FollowingTemplate {
pub ctx: HtmlPageContext,
pub user_id: uuid::Uuid,
pub actors: Vec<RemoteActorData>,
pub pending_actors: Vec<RemoteActorData>,
pub error: Option<String>,
}
#[derive(Template)]
#[template(path = "followers.html")]
pub struct FollowersTemplate {
pub ctx: HtmlPageContext,
pub user_id: uuid::Uuid,
pub actors: Vec<RemoteActorData>,
pub error: Option<String>,
}
#[derive(Template)]
#[template(path = "blocked_domains.html")]
pub struct BlockedDomainsTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub domains: &'a [BlockedDomainEntry],
}
#[derive(Template)]
#[template(path = "blocked_actors.html")]
pub struct BlockedActorsTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub actors: &'a [BlockedActorEntry],
}
pub struct BlockedDomainEntry {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
pub struct BlockedActorEntry {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
pub struct HeatmapCell {
pub month_label: String,
pub count: i64,
pub alpha: f64,
}
pub fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
let current_year = chrono::Utc::now().year();
let count_for = |m: &str| -> i64 {
history
.iter()
.find(|a| a.year_month == format!("{}-{}", current_year, m))
.map(|a| a.count)
.unwrap_or(0)
};
let months = [
("01", "Jan"),
("02", "Feb"),
("03", "Mar"),
("04", "Apr"),
("05", "May"),
("06", "Jun"),
("07", "Jul"),
("08", "Aug"),
("09", "Sep"),
("10", "Oct"),
("11", "Nov"),
("12", "Dec"),
];
let counts: Vec<i64> = months.iter().map(|(m, _)| count_for(m)).collect();
let max = counts.iter().copied().max().unwrap_or(0).max(1);
months
.iter()
.zip(counts.iter())
.map(|((_, label), &count)| {
let alpha = if count == 0 {
0.05
} else {
0.15 + 0.75 * (count as f64 / max as f64)
};
HeatmapCell {
month_label: label.to_string(),
count,
alpha,
}
})
.collect()
}
pub fn bar_height_px(avg_rating: f64) -> i64 {
(avg_rating / 5.0 * 60.0) as i64
}
#[derive(Template)]
#[template(path = "profile_settings.html")]
pub struct ProfileSettingsTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub bio: Option<&'a str>,
pub avatar_url: Option<&'a str>,
pub banner_url: Option<&'a str>,
pub also_known_as: Option<&'a str>,
pub profile_fields: &'a [(String, String)],
pub saved: bool,
pub embed_url: String,
}
#[derive(Template)]
#[template(path = "integrations.html")]
pub struct IntegrationsTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub tokens: &'a [WebhookTokenView],
pub webhook_base_url: &'a str,
pub new_token: Option<&'a str>,
}
pub struct WebhookTokenView {
pub id: String,
pub provider: String,
pub label: Option<String>,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Template)]
#[template(path = "watch_queue.html")]
pub struct WatchQueueTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub entries: &'a [WatchQueueDisplayEntry],
pub error: Option<&'a str>,
}
pub struct WatchQueueDisplayEntry {
pub id: String,
pub title: String,
pub year: Option<u16>,
pub source: String,
pub watched_at: String,
pub movie_url: Option<String>,
}
#[derive(Template)]
#[template(path = "import_upload.html")]
pub struct ImportUploadTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub profiles: &'a [ImportProfileView],
pub error: Option<&'a str>,
}
pub struct ImportProfileView {
pub id: String,
pub name: String,
}
#[derive(Template)]
#[template(path = "import_mapping.html")]
pub struct ImportMappingTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub session_id: &'a str,
pub columns: &'a [String],
pub sample_rows: &'a [Vec<String>],
pub domain_fields: &'a [(&'static str, &'static str)],
pub error: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "import_preview.html")]
pub struct ImportPreviewTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub session_id: &'a str,
pub columns: &'a [String],
pub rows: &'a [ImportPreviewRow],
}
pub struct ImportPreviewRow {
pub index: usize,
pub status: ImportRowStatus,
pub cells: Vec<String>,
}
pub enum ImportRowStatus {
Valid,
Duplicate,
Invalid(String),
}
#[derive(Template)]
#[template(path = "wrapup.html")]
pub struct WrapUpPageTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub report: &'a domain::models::wrapup::WrapUpReport,
pub year_label: String,
pub watch_time_display: String,
pub rating_max: u32,
pub genre_max: u32,
pub rating_pcts: [f64; 5],
pub genre_pcts: Vec<f64>,
}

View File

@@ -0,0 +1,15 @@
pub mod context;
pub mod csrf;
pub mod errors;
pub mod extractors;
pub mod forms;
pub mod handlers;
pub mod html;
pub mod mappers;
pub mod openapi;
pub mod render;
pub mod routes;
pub mod state;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,20 @@
use api_types::FeedEntryDto;
use domain::models::FeedEntry;
use super::movies::{movie_to_dto, review_to_dto};
pub fn feed_entry_to_dto(e: &FeedEntry) -> FeedEntryDto {
use domain::models::ReviewSource;
let actor_url = match e.review().source() {
ReviewSource::Remote { actor_url } => Some(actor_url.clone()),
ReviewSource::Local => None,
};
FeedEntryDto {
movie: movie_to_dto(e.movie()),
review: review_to_dto(e.review()),
user_id: e.review().user_id().value(),
user_display_name: e.user_display_name().to_string(),
is_federated: e.review().is_remote(),
actor_url,
}
}

View File

@@ -0,0 +1,32 @@
use crate::html::templates::{ImportPreviewRow, ImportRowStatus};
use domain::models::AnnotatedRow;
use domain::models::import::RowResult;
pub fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow {
match &annotated.result {
RowResult::Valid(row) => {
let cells = vec![
row.title.clone().unwrap_or_default(),
row.release_year.clone().unwrap_or_default(),
row.director.clone().unwrap_or_default(),
row.rating.clone().unwrap_or_default(),
row.watched_at.clone().unwrap_or_default(),
row.comment.clone().unwrap_or_default(),
];
ImportPreviewRow {
index: idx,
status: if annotated.is_duplicate {
ImportRowStatus::Duplicate
} else {
ImportRowStatus::Valid
},
cells,
}
}
RowResult::Invalid { errors, raw } => ImportPreviewRow {
index: idx,
status: ImportRowStatus::Invalid(errors.join("; ")),
cells: raw.iter().map(|(_, v)| v.clone()).collect(),
},
}
}

View File

@@ -0,0 +1,25 @@
use crate::html::templates::{WatchQueueDisplayEntry, WebhookTokenView};
use domain::models::{WatchEvent, WebhookToken};
pub fn webhook_token_view(t: &WebhookToken) -> WebhookTokenView {
WebhookTokenView {
id: t.id().value().to_string(),
provider: t.provider().to_string(),
label: t.label().map(String::from),
created_at: t.created_at().format("%Y-%m-%d %H:%M").to_string(),
last_used_at: t
.last_used_at()
.map(|d| d.format("%Y-%m-%d %H:%M").to_string()),
}
}
pub fn watch_queue_entry(e: &WatchEvent) -> WatchQueueDisplayEntry {
WatchQueueDisplayEntry {
id: e.id().value().to_string(),
title: e.title().to_string(),
year: e.year(),
source: e.source().to_string(),
watched_at: e.watched_at().format("%Y-%m-%d %H:%M").to_string(),
movie_url: e.movie_id().map(|m| format!("/movies/{}", m.value())),
}
}

View File

@@ -0,0 +1,7 @@
pub mod diary;
pub mod import;
pub mod integrations;
pub mod movies;
pub mod search;
pub mod users;
pub mod watchlist;

View File

@@ -0,0 +1,50 @@
use api_types::{DiaryEntryDto, MovieDto, ReviewDto};
use domain::models::{DiaryEntry, Movie, MovieSummary, Review};
pub fn movie_to_dto(movie: &Movie) -> MovieDto {
MovieDto {
id: movie.id().value(),
title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
director: movie.director().map(|d| d.to_string()),
poster_path: movie.poster_path().map(|p| p.value().to_string()),
genres: vec![],
runtime_minutes: None,
original_language: None,
overview: None,
collection_name: None,
}
}
pub fn summary_to_dto(summary: &MovieSummary) -> MovieDto {
MovieDto {
id: summary.movie.id().value(),
title: summary.movie.title().value().to_string(),
release_year: summary.movie.release_year().value(),
director: summary.movie.director().map(|d| d.to_string()),
poster_path: summary.movie.poster_path().map(|p| p.value().to_string()),
genres: summary.genres.clone(),
runtime_minutes: summary.runtime_minutes,
original_language: summary.original_language.clone(),
overview: summary.overview.clone(),
collection_name: summary.collection_name.clone(),
}
}
pub fn review_to_dto(review: &Review) -> ReviewDto {
ReviewDto {
id: review.id().value(),
rating: review.rating().value(),
comment: review.comment().map(|c| c.value().to_string()),
watched_at: domain::value_objects::format_watched_at(review.watched_at()),
watch_medium: review.watch_medium().copied(),
spoilers: review.spoilers(),
}
}
pub fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
DiaryEntryDto {
movie: movie_to_dto(entry.movie()),
review: review_to_dto(entry.review()),
}
}

View File

@@ -0,0 +1,22 @@
use api_types::search::PersonDto;
use domain::models::person::Person;
pub fn person_to_dto(p: &Person) -> PersonDto {
PersonDto {
id: p.id().value(),
external_id: p.external_id().value().to_string(),
name: p.name().to_string(),
known_for_department: p.known_for_department().map(str::to_string),
profile_path: p.profile_path().map(str::to_string),
biography: p.biography().map(str::to_string),
birthday: p.birthday().map(|d| d.to_string()),
deathday: p.deathday().map(|d| d.to_string()),
place_of_birth: p.place_of_birth().map(str::to_string),
also_known_as: p.also_known_as().to_vec(),
homepage: p.homepage().map(str::to_string),
imdb_url: p
.imdb_id()
.map(|id| format!("https://www.imdb.com/name/{id}")),
enriched: p.enriched_at().is_some(),
}
}

View File

@@ -0,0 +1,45 @@
use crate::html::templates::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
use application::users::get_local_profile::PendingFollowerView;
use domain::models::RemoteActorInfo;
use domain::models::UserSummary;
pub fn user_summary_view(u: &UserSummary) -> UserSummaryView {
let name = u
.display_name()
.map(String::from)
.unwrap_or_else(|| u.username().to_string());
let initial = name.chars().next().unwrap_or('?').to_ascii_uppercase();
let avg_display = u
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "\u{2014}".to_string());
let avatar_url = u.avatar_path.as_ref().map(|p| format!("/images/{}", p));
UserSummaryView {
user_id: u.user_id.value(),
display_name: name,
initial,
avg_rating_display: avg_display,
total_movies: u.total_movies,
avatar_url,
}
}
pub fn remote_actor_display(a: &RemoteActorInfo) -> RemoteActorDisplay {
let display = a.display_name.clone().unwrap_or_else(|| a.handle.clone());
let initial = display.chars().next().unwrap_or('?').to_ascii_uppercase();
RemoteActorDisplay {
handle: a.handle.clone(),
display_name: display,
initial,
url: a.url.clone(),
}
}
pub fn pending_follower_data(p: &PendingFollowerView) -> RemoteActorData {
RemoteActorData {
handle: p.handle.clone(),
url: p.url.clone(),
display_name: p.display_name.clone(),
avatar_url: p.avatar_url.clone(),
}
}

View File

@@ -0,0 +1,65 @@
use crate::html::templates::WatchlistDisplayEntry;
use domain::models::{RemoteWatchlistEntry, WatchlistWithMovie, collections::Paginated};
pub struct WatchlistPageResult {
pub display_entries: Vec<WatchlistDisplayEntry>,
pub has_more: bool,
pub current_offset: u32,
pub limit: u32,
}
pub fn build_watchlist_page(
page: Paginated<WatchlistWithMovie>,
is_owner: bool,
) -> WatchlistPageResult {
let has_more = page.offset + page.limit < page.total_count as u32;
let display_entries = page
.items
.iter()
.map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w
.movie
.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
})
.collect();
WatchlistPageResult {
display_entries,
has_more,
current_offset: page.offset,
limit: page.limit,
}
}
pub fn build_remote_watchlist_page(entries: Vec<RemoteWatchlistEntry>) -> WatchlistPageResult {
let len = entries.len() as u32;
let display_entries = entries
.into_iter()
.map(|e| WatchlistDisplayEntry {
poster_url: e.poster_url,
movie_title: e.movie_title,
release_year: e.release_year,
movie_url: None,
added_at: e.added_at.format("%b %-d, %Y").to_string(),
remove_url: None,
})
.collect();
WatchlistPageResult {
display_entries,
has_more: false,
current_offset: 0,
limit: len,
}
}

View File

@@ -0,0 +1,9 @@
use api_types::{LoginRequest, LoginResponse, RegisterRequest};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(crate::handlers::auth::login, crate::handlers::auth::register,),
components(schemas(LoginRequest, LoginResponse, RegisterRequest))
)]
pub struct AuthDoc;

View File

@@ -0,0 +1,24 @@
use api_types::{
ActivityFeedResponse, DiaryEntryDto, DiaryResponse, FeedEntryDto, LogReviewRequest, ReviewDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::diary::get_diary,
crate::handlers::diary::post_review,
crate::handlers::diary::delete_review,
crate::handlers::diary::export_diary,
crate::handlers::diary::get_activity_feed,
),
components(schemas(
DiaryResponse,
DiaryEntryDto,
ReviewDto,
LogReviewRequest,
ActivityFeedResponse,
FeedEntryDto,
))
)]
pub struct DiaryDoc;

View File

@@ -0,0 +1,27 @@
use api_types::{
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
UserSettingsDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::goals::list_goals,
crate::handlers::goals::create_goal,
crate::handlers::goals::update_goal,
crate::handlers::goals::delete_goal,
crate::handlers::goals::get_user_goals,
crate::handlers::goals::get_settings,
crate::handlers::goals::update_settings,
),
components(schemas(
GoalDto,
GoalsResponse,
CreateGoalRequest,
UpdateGoalRequest,
UserSettingsDto,
UpdateUserSettingsRequest,
))
)]
pub struct GoalsDoc;

View File

@@ -0,0 +1,27 @@
use api_types::{
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
SessionCreatedResponse, SessionStateResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::import::api_post_session,
crate::handlers::import::api_get_session,
crate::handlers::import::api_put_mapping,
crate::handlers::import::api_post_confirm,
crate::handlers::import::api_get_profiles,
crate::handlers::import::api_post_profile,
crate::handlers::import::api_delete_profile,
),
components(schemas(
SessionCreatedResponse,
SessionStateResponse,
ApiFieldMapping,
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
))
)]
pub struct ImportDoc;

View File

@@ -0,0 +1,8 @@
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(crate::handlers::insights::get_insights,),
components(schemas(api_types::insights::InsightsResponse, api_types::insights::InsightDto,))
)]
pub struct InsightsDoc;

View File

@@ -0,0 +1,63 @@
mod auth;
mod diary;
mod goals;
mod import;
mod insights;
mod movies;
mod search;
mod social;
mod users;
mod watchlist;
mod webhook;
mod wrapup;
use axum::Router;
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
use utoipa_scalar::{Scalar, Servable};
use utoipa_swagger_ui::SwaggerUi;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
fn build() -> utoipa::openapi::OpenApi {
let mut api = auth::AuthDoc::openapi();
api.info = utoipa::openapi::InfoBuilder::new()
.title("Movies Diary API")
.version("1.0.0")
.description(Some("REST API for Movies Diary. Authenticate with `POST /api/v1/auth/login` to get a Bearer token."))
.build();
api.merge(diary::DiaryDoc::openapi());
api.merge(movies::MoviesDoc::openapi());
api.merge(users::UsersDoc::openapi());
api.merge(import::ImportDoc::openapi());
api.merge(search::SearchDoc::openapi());
api.merge(watchlist::WatchlistDoc::openapi());
api.merge(goals::GoalsDoc::openapi());
api.merge(webhook::WebhookDoc::openapi());
api.merge(wrapup::WrapUpDoc::openapi());
api.merge(insights::InsightsDoc::openapi());
#[cfg(feature = "federation")]
api.merge(social::SocialDoc::openapi());
SecurityAddon.modify(&mut api);
api
}
pub fn serve(router: Router) -> Router {
tracing::info!("API docs at /docs (Swagger) and /scalar");
let spec = build();
router
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
.merge(Scalar::with_url("/scalar", spec))
}

View File

@@ -0,0 +1,36 @@
use api_types::{
CastMemberDto, CrewMemberDto, DirectorStatDto, GenreDto, KeywordDto, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
MoviesResponse, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserTrendsDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::movies::list_movies,
crate::handlers::movies::get_movie_detail,
crate::handlers::movies::get_review_history,
crate::handlers::movies::get_movie_profile,
crate::handlers::movies::sync_poster,
),
components(schemas(
MoviesResponse,
MovieDto,
MovieDetailResponse,
MovieStatsDto,
MovieProfileResponse,
GenreDto,
KeywordDto,
CastMemberDto,
CrewMemberDto,
ReviewHistoryResponse,
SocialFeedResponse,
SocialReviewDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
))
)]
pub struct MoviesDoc;

View File

@@ -0,0 +1,29 @@
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::search::get_search,
crate::handlers::search::get_person_handler,
crate::handlers::search::get_person_credits_handler,
),
components(schemas(
SearchResponse,
PaginatedMovieHits,
PaginatedPersonHits,
MovieSearchHitDto,
PersonSearchHitDto,
PersonDto,
PersonCreditsDto,
CastCreditDto,
CrewCreditDto,
)),
tags(
(name = "search", description = "Full-text search across movies and people"),
),
)]
pub struct SearchDoc;

View File

@@ -0,0 +1,44 @@
#[cfg(feature = "federation")]
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRelationResponse, FollowRequest, PendingCountResponse,
RemoteActorDto,
};
#[cfg(feature = "federation")]
use utoipa::OpenApi;
#[cfg(feature = "federation")]
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::social::get_following,
crate::handlers::social::get_followers,
crate::handlers::social::get_pending_followers,
crate::handlers::social::get_pending_follower_count,
crate::handlers::social::get_pending_following,
crate::handlers::social::get_relationship,
crate::handlers::social::follow,
crate::handlers::social::unfollow,
crate::handlers::social::accept_follower,
crate::handlers::social::reject_follower,
crate::handlers::social::remove_follower,
crate::handlers::social::get_blocked_domains_admin,
crate::handlers::social::add_blocked_domain_admin,
crate::handlers::social::remove_blocked_domain_admin,
crate::handlers::social::block_actor_api,
crate::handlers::social::unblock_actor_api,
crate::handlers::social::get_blocked_actors_api,
),
components(schemas(
ActorListResponse,
RemoteActorDto,
FollowRequest,
ActorUrlRequest,
BlockedDomainResponse,
AddBlockedDomainRequest,
BlockedActorResponse,
FollowRelationResponse,
PendingCountResponse,
))
)]
pub struct SocialDoc;

View File

@@ -0,0 +1,28 @@
use api_types::{
ProfileFieldDto, ProfileResponse, ProfileViewData, UpdateProfileFieldsRequest, UserProfileBase,
UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::users::list_users,
crate::handlers::users::get_user_profile,
crate::handlers::users::get_profile,
crate::handlers::users::update_profile_handler,
crate::handlers::users::update_profile_fields_handler,
),
components(schemas(
UsersResponse,
UserSummaryDto,
UserProfileBase,
UserProfileResponse,
ProfileViewData,
UserStatsDto,
ProfileResponse,
UpdateProfileFieldsRequest,
ProfileFieldDto,
))
)]
pub struct UsersDoc;

View File

@@ -0,0 +1,21 @@
use api_types::{
AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::watchlist::get_watchlist_handler,
crate::handlers::watchlist::post_watchlist_add,
crate::handlers::watchlist::delete_watchlist_entry,
crate::handlers::watchlist::get_watchlist_status,
),
components(schemas(
WatchlistResponse,
WatchlistEntryDto,
AddToWatchlistRequest,
WatchlistStatusResponse,
))
)]
pub struct WatchlistDoc;

View File

@@ -0,0 +1,32 @@
use api_types::{
ConfirmWatchEntry, ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest,
DismissWatchResponse, GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto,
WebhookTokenDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::webhook::post_jellyfin_webhook,
crate::handlers::webhook::post_plex_webhook,
crate::handlers::webhook::post_generate_webhook_token,
crate::handlers::webhook::get_webhook_tokens,
crate::handlers::webhook::delete_webhook_token,
crate::handlers::webhook::get_watch_queue,
crate::handlers::webhook::post_confirm_watch_events,
crate::handlers::webhook::post_dismiss_watch_events,
),
components(schemas(
GenerateTokenRequest,
GenerateTokenResponse,
WebhookTokenDto,
WatchQueueEntryDto,
ConfirmWatchRequest,
ConfirmWatchEntry,
ConfirmWatchResponse,
DismissWatchRequest,
DismissWatchResponse,
))
)]
pub struct WebhookDoc;

View File

@@ -0,0 +1,19 @@
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::wrapup::post_generate,
crate::handlers::wrapup::get_list,
crate::handlers::wrapup::get_status,
crate::handlers::wrapup::get_report,
crate::handlers::wrapup::delete_wrapup_handler,
),
components(schemas(
api_types::wrapup::GenerateWrapUpRequest,
api_types::wrapup::WrapUpGeneratedResponse,
api_types::wrapup::WrapUpStatusResponse,
api_types::wrapup::WrapUpListResponse,
))
)]
pub struct WrapUpDoc;

View File

@@ -0,0 +1,14 @@
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
pub fn render_page(template: impl askama::Template) -> Response {
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("template error: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}

View File

@@ -0,0 +1,593 @@
use std::num::NonZeroU32;
use axum::{Router, routing};
use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp};
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use crate::{handlers, state::AppState};
pub fn build_router(state: AppState, ap_router: Router) -> Router {
let rate_limit = state.app_ctx.config.rate_limit;
let secure_cookies = state.app_ctx.config.secure_cookies;
let cors_origins = state.app_ctx.config.cors_origins.clone();
let ap_cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit / 2))
.finish()
.unwrap();
let ap_router = ap_router.layer(GovernorLayer::new(ap_cfg));
Router::new()
.route("/health", routing::get(health_handler))
.merge(html_routes(rate_limit, secure_cookies))
.merge(api_routes(rate_limit, &cors_origins))
.nest_service("/static", ServeDir::new("static"))
.nest_service(
"/app",
ServeDir::new("spa/dist").fallback(ServeFile::new("spa/dist/index.html")),
)
.layer(TraceLayer::new_for_http())
.with_state(state)
.merge(ap_router)
}
async fn health_handler() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "status": "ok" }))
}
fn per_minute(n: u64) -> Quota {
let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap();
Quota::requests_per_minute(n)
}
fn html_routes(rate_limit: u64, secure_cookies: bool) -> Router<AppState> {
let auth = Router::new()
.route(
"/login",
routing::get(handlers::auth::get_login_page).post(handlers::auth::post_login),
)
.route("/logout", routing::get(handlers::auth::get_logout))
.route(
"/register",
routing::get(handlers::auth::get_register_page).post(handlers::auth::post_register),
)
.layer({
let cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit))
.finish()
.unwrap();
GovernorLayer::new(cfg)
});
let base = Router::new()
.route("/", routing::get(handlers::diary::get_activity_feed_html))
.route("/users", routing::get(handlers::users::get_users_list))
.route(
"/u/{username}",
routing::get(handlers::users::get_user_by_username),
)
.route(
"/users/{id}",
routing::get(handlers::users::get_user_profile_html),
)
.route(
"/movies/{movie_id}",
routing::get(handlers::movies::get_movie_detail_html),
)
.merge(auth)
.route(
"/reviews/new",
routing::get(handlers::diary::get_new_review_page),
)
.route("/reviews", routing::post(handlers::diary::post_review_html))
.route(
"/reviews/{id}/delete",
routing::post(handlers::diary::post_delete_review_html),
)
.route("/images/{*key}", routing::get(handlers::images::get_image))
.route(
"/posters/{path}",
routing::get(
|axum::extract::Path(p): axum::extract::Path<String>| async move {
axum::response::Redirect::permanent(&format!("/images/{}", p))
},
),
)
.route(
"/diary/export",
routing::get(handlers::diary::get_export_html),
)
.route("/import", routing::get(handlers::import::get_import_page))
.route(
"/import/upload",
routing::post(handlers::import::post_upload),
)
.route(
"/import/{id}/mapping",
routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping),
)
.route(
"/import/{id}/preview",
routing::get(handlers::import::get_preview_page),
)
.route(
"/import/{id}/confirm",
routing::post(handlers::import::post_confirm),
)
.route(
"/import/done",
routing::get(handlers::import::get_import_done),
)
.route(
"/import/profiles/{profile_id}/delete",
routing::post(handlers::import::post_delete_profile),
)
.route("/feed.rss", routing::get(handlers::rss::get_feed))
.route(
"/users/{id}/feed.rss",
routing::get(handlers::rss::get_user_feed),
)
.route(
"/settings/profile",
routing::get(handlers::users::get_profile_settings)
.post(handlers::users::post_profile_settings),
)
.route("/tags/{tag}", routing::get(handlers::search::get_tag))
.route(
"/users/{id}/watchlist",
routing::get(handlers::watchlist::get_watchlist_page),
)
.route(
"/watchlist/add",
routing::post(handlers::watchlist::post_watchlist_add_html),
)
.route(
"/watchlist/{movie_id}/remove",
routing::post(handlers::watchlist::post_watchlist_remove_html),
)
.route(
"/settings/integrations",
routing::get(handlers::integrations::get_integrations_page),
)
.route(
"/settings/integrations/generate",
routing::post(handlers::integrations::post_generate_token),
)
.route(
"/settings/integrations/{id}/revoke",
routing::post(handlers::integrations::post_revoke_token),
)
.route(
"/watch-queue",
routing::get(handlers::integrations::get_watch_queue_page),
)
.route(
"/watch-queue/{id}/confirm",
routing::post(handlers::integrations::post_confirm_single),
)
.route(
"/watch-queue/{id}/dismiss",
routing::post(handlers::integrations::post_dismiss_single),
)
.route(
"/wrapups/{user_id}/{year}",
routing::get(handlers::wrapup::get_user_wrapup_html),
)
.route(
"/wrapups/global/{year}",
routing::get(handlers::wrapup::get_global_wrapup_html),
);
let base = base.merge(social_html_routes());
#[cfg(feature = "federation")]
let base = base.merge(federation_html_routes());
base.layer(axum::middleware::from_fn_with_state(
crate::csrf::SecureCookies(secure_cookies),
crate::csrf::csrf_middleware,
))
}
fn social_html_routes() -> Router<AppState> {
let base = Router::new()
.route(
"/users/{id}/follow",
routing::post(handlers::social::follow_user),
)
.route(
"/users/{id}/unfollow",
routing::post(handlers::social::unfollow_user),
)
.route(
"/users/{id}/followers/accept",
routing::post(handlers::social::accept_follower_html),
)
.route(
"/users/{id}/followers/reject",
routing::post(handlers::social::reject_follower_html),
)
.route(
"/users/{id}/followers/remove",
routing::post(handlers::social::remove_follower_html),
)
.route(
"/users/{id}/following-list",
routing::get(handlers::social::get_following_page),
)
.route(
"/users/{id}/followers-list",
routing::get(handlers::social::get_followers_page),
);
#[cfg(not(feature = "federation"))]
let base = base
.route(
"/users/{id}/followers",
routing::get(
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id))
},
),
)
.route(
"/users/{id}/following",
routing::get(
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
axum::response::Redirect::permanent(&format!("/users/{}/following-list", id))
},
),
);
base
}
#[cfg(feature = "federation")]
fn federation_html_routes() -> Router<AppState> {
Router::new()
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_followers_collection),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_following_collection),
)
.route(
"/admin/blocked-domains",
routing::get(handlers::social::get_blocked_domains_page)
.post(handlers::social::post_blocked_domain),
)
.route(
"/admin/blocked-domains/remove",
routing::post(handlers::social::post_remove_blocked_domain),
)
.route(
"/social/blocked",
routing::get(handlers::social::get_blocked_actors_page),
)
.route(
"/social/block",
routing::post(handlers::social::post_block_actor_html),
)
.route(
"/social/unblock",
routing::post(handlers::social::post_unblock_actor),
)
}
fn cors_layer(origins: &str) -> CorsLayer {
use axum::http::{HeaderName, Method};
use tower_http::cors::AllowOrigin;
let layer = CorsLayer::new()
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
HeaderName::from_static("content-type"),
HeaderName::from_static("authorization"),
]);
if origins.is_empty() || origins == "*" {
layer.allow_origin(AllowOrigin::any())
} else {
let parsed: Vec<_> = origins
.split(',')
.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))
.allow_credentials(true)
}
}
fn api_routes(rate_limit: u64, cors_origins: &str) -> Router<AppState> {
let cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit))
.finish()
.unwrap();
let base = Router::new()
.route("/diary", routing::get(handlers::diary::get_diary))
.route(
"/movies/{id}/history",
routing::get(handlers::movies::get_review_history),
)
.route("/movies", routing::get(handlers::movies::list_movies))
.route(
"/movies/{id}",
routing::get(handlers::movies::get_movie_detail),
)
.route(
"/movies/{id}/profile",
routing::get(handlers::movies::get_movie_profile),
)
.route("/reviews", routing::post(handlers::diary::post_review))
.route(
"/reviews/{id}",
routing::delete(handlers::diary::delete_review).patch(handlers::diary::patch_review),
)
.route(
"/movies/{id}/sync-poster",
routing::post(handlers::movies::sync_poster),
)
.route("/auth/login", routing::post(handlers::auth::login))
.route("/auth/register", routing::post(handlers::auth::register))
.route("/auth/refresh", routing::post(handlers::auth::refresh))
.route("/auth/logout", routing::post(handlers::auth::api_logout))
.route("/diary/export", routing::get(handlers::diary::export_diary))
.route(
"/activity-feed",
routing::get(handlers::diary::get_activity_feed),
)
.route("/users", routing::get(handlers::users::list_users))
.route(
"/users/{id}",
routing::get(handlers::users::get_user_profile),
)
.route(
"/import/sessions",
routing::post(handlers::import::api_post_session),
)
.route(
"/import/sessions/{id}",
routing::get(handlers::import::api_get_session),
)
.route(
"/import/sessions/{id}/mapping",
routing::put(handlers::import::api_put_mapping),
)
.route(
"/import/sessions/{id}/preview",
routing::get(handlers::import::api_get_preview),
)
.route(
"/import/sessions/{id}/confirm",
routing::post(handlers::import::api_post_confirm),
)
.route(
"/import/profiles",
routing::get(handlers::import::api_get_profiles)
.post(handlers::import::api_post_profile),
)
.route(
"/import/profiles/{id}",
routing::delete(handlers::import::api_delete_profile),
)
.route(
"/import/sessions/{id}/profile/{profile_id}",
routing::put(handlers::import::api_apply_profile),
)
.route(
"/profile",
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),
)
.route(
"/profile/fields",
routing::put(handlers::users::update_profile_fields_handler),
)
.route("/search", routing::get(handlers::search::get_search))
.route(
"/people/{id}",
routing::get(handlers::search::get_person_handler),
)
.route(
"/people/{id}/credits",
routing::get(handlers::search::get_person_credits_handler),
)
.route(
"/watchlist",
routing::get(handlers::watchlist::get_watchlist_handler)
.post(handlers::watchlist::post_watchlist_add),
)
.route(
"/watchlist/{movie_id}",
routing::get(handlers::watchlist::get_watchlist_status)
.delete(handlers::watchlist::delete_watchlist_entry),
)
.route(
"/settings/webhook-tokens",
routing::get(handlers::webhook::get_webhook_tokens)
.post(handlers::webhook::post_generate_webhook_token),
)
.route(
"/settings/webhook-tokens/{id}",
routing::delete(handlers::webhook::delete_webhook_token),
)
.route(
"/watch-queue",
routing::get(handlers::webhook::get_watch_queue),
)
.route(
"/watch-queue/confirm",
routing::post(handlers::webhook::post_confirm_watch_events),
)
.route(
"/watch-queue/dismiss",
routing::post(handlers::webhook::post_dismiss_watch_events),
)
.route(
"/wrapups/generate",
routing::post(handlers::wrapup::post_generate),
)
.route("/wrapups", routing::get(handlers::wrapup::get_list))
.route(
"/wrapups/{id}",
routing::get(handlers::wrapup::get_status)
.delete(handlers::wrapup::delete_wrapup_handler),
)
.route(
"/wrapups/{id}/report",
routing::get(handlers::wrapup::get_report),
)
.route("/insights", routing::get(handlers::insights::get_insights))
.route(
"/admin/reindex-search",
routing::post(handlers::search::post_reindex_search),
)
.route(
"/goals",
routing::get(handlers::goals::list_goals).post(handlers::goals::create_goal),
)
.route(
"/goals/{year}",
routing::put(handlers::goals::update_goal).delete(handlers::goals::delete_goal),
)
.route(
"/users/{id}/goals",
routing::get(handlers::goals::get_user_goals),
)
.route(
"/settings",
routing::get(handlers::goals::get_settings).put(handlers::goals::update_settings),
);
let base = base.merge(social_api_routes());
#[cfg(feature = "federation")]
let base = base.merge(federation_api_routes());
let webhook_cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit / 4))
.finish()
.unwrap();
let webhook_routes = Router::new()
.route(
"/webhooks/jellyfin",
routing::post(handlers::webhook::post_jellyfin_webhook),
)
.route(
"/webhooks/plex",
routing::post(handlers::webhook::post_plex_webhook),
)
.layer(GovernorLayer::new(webhook_cfg));
Router::new()
.nest("/api/v1", base.layer(GovernorLayer::new(cfg)))
.nest("/api/v1", webhook_routes)
.layer(cors_layer(cors_origins))
}
fn social_api_routes() -> Router<AppState> {
Router::new()
.route(
"/social/following",
routing::get(handlers::social::get_following),
)
.route(
"/social/followers",
routing::get(handlers::social::get_followers),
)
.route(
"/social/followers/pending",
routing::get(handlers::social::get_pending_followers),
)
.route(
"/social/followers/pending/count",
routing::get(handlers::social::get_pending_follower_count),
)
.route(
"/social/following/pending",
routing::get(handlers::social::get_pending_following),
)
.route(
"/social/relationship",
routing::get(handlers::social::get_relationship),
)
.route("/social/follow", routing::post(handlers::social::follow))
.route(
"/social/unfollow",
routing::post(handlers::social::unfollow),
)
.route(
"/social/followers/accept",
routing::post(handlers::social::accept_follower),
)
.route(
"/social/followers/reject",
routing::post(handlers::social::reject_follower),
)
.route(
"/social/followers/remove",
routing::post(handlers::social::remove_follower),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_user_following),
)
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_user_followers),
)
}
#[cfg(feature = "federation")]
fn federation_api_routes() -> Router<AppState> {
Router::new()
.route(
"/admin/blocked-domains",
routing::get(handlers::social::get_blocked_domains_admin)
.post(handlers::social::add_blocked_domain_admin),
)
.route(
"/admin/blocked-domains/{domain}",
routing::delete(handlers::social::remove_blocked_domain_admin),
)
.route(
"/social/block",
routing::post(handlers::social::block_actor_api),
)
.route(
"/social/unblock",
routing::post(handlers::social::unblock_actor_api),
)
.route(
"/social/blocked",
routing::get(handlers::social::get_blocked_actors_api),
)
}

View File

@@ -0,0 +1,11 @@
use std::sync::Arc;
use crate::context::AppContext;
use domain::ports::RssFeedRenderer;
#[derive(Clone)]
pub struct AppState {
pub app_ctx: AppContext,
pub rss_renderer: Arc<dyn RssFeedRenderer>,
}

View File

@@ -0,0 +1,395 @@
use crate::extractors::tests::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::errors::DomainError;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
// Custom stub for SearchPort that returns empty results instead of panicking
struct SearchPortStub;
#[async_trait::async_trait]
impl domain::ports::SearchPort for SearchPortStub {
async fn search(
&self,
_: &domain::models::SearchQuery,
) -> Result<domain::models::SearchResults, DomainError> {
Ok(domain::models::SearchResults {
movies: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
people: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
})
}
}
// Custom stub for PersonQuery that returns 404 instead of panicking
struct PersonQueryStub;
#[async_trait::async_trait]
impl domain::ports::PersonQuery for PersonQueryStub {
async fn get_by_id(
&self,
_: &domain::models::PersonId,
) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None) // Return None to trigger 404
}
async fn get_by_external_id(
&self,
_: &domain::models::ExternalPersonId,
) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None)
}
async fn get_credits(
&self,
_: &domain::models::PersonId,
) -> Result<domain::models::PersonCredits, DomainError> {
Err(DomainError::NotFound("Person not found".into()))
}
async fn list_orphaned_persons(&self) -> Result<Vec<domain::models::PersonId>, DomainError> {
Ok(vec![])
}
async fn list_page(
&self,
_limit: u32,
_offset: u32,
) -> Result<Vec<domain::models::Person>, DomainError> {
Ok(vec![])
}
}
// --- Search endpoint tests ---
#[tokio::test]
async fn search_endpoint_returns_200_with_empty_results() {
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::search::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=test&limit=10&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_endpoint_with_no_query_returns_200() {
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::search::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=&limit=5&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// --- Person endpoint tests ---
#[tokio::test]
async fn person_endpoint_returns_404_for_unknown_id() {
let state =
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(crate::handlers::search::get_person_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn person_credits_endpoint_returns_404_for_unknown_id() {
let state =
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}/credits",
get(crate::handlers::search::get_person_credits_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}/credits", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn injected_stub_reaches_prebuilt_deps() {
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
let state = crate::extractors::tests::test_app_state_with_person_query(Arc::clone(&stub));
assert!(
Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub),
"prebuilt deps must hold the injected stub too, or handlers reading deps \
will silently use the real dependency"
);
}
// --- Watchlist endpoint tests ---
#[tokio::test]
async fn get_watchlist_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist",
get(crate::handlers::watchlist::get_watchlist_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Pending-follower-count endpoint tests ---
struct FixedPendingCount(usize);
#[async_trait::async_trait]
impl domain::ports::FollowGraphQuery for FixedPendingCount {
async fn get_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn count_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(self.0)
}
async fn get_relation(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<domain::value_objects::FollowRelation, DomainError> {
unreachable!()
}
}
#[tokio::test]
async fn pending_count_endpoint_reports_the_use_case_result() {
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
let state = test_app_state_with_follow_graph(
Arc::new(FixedPendingCount(3)),
Arc::new(AcceptingAuth(uid)),
);
let app = Router::new()
.route(
"/api/v1/social/followers/pending/count",
get(crate::handlers::social::get_pending_follower_count),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/social/followers/pending/count")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["count"], 3);
}
#[tokio::test]
async fn get_watchlist_status_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist/{movie_id}",
get(crate::handlers::watchlist::get_watchlist_status),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Federation collection error-handling tests ---
#[cfg(feature = "federation")]
struct FailingApDocument;
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::ApDocumentPort for FailingApDocument {
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn followers_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn following_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
}
#[cfg(feature = "federation")]
fn state_with_failing_ap_document() -> crate::state::AppState {
let mut state = test_app_state();
state.app_ctx.ap_document = Arc::new(FailingApDocument);
state
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_collection_returns_500_on_federation_error() {
let app = Router::new()
.route(
"/users/{id}/followers",
get(crate::handlers::social::get_followers_collection),
)
.with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/followers", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn following_collection_returns_500_on_federation_error() {
let app = Router::new()
.route(
"/users/{id}/following",
get(crate::handlers::social::get_following_collection),
)
.with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/following", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

View File

@@ -0,0 +1,26 @@
#[test]
fn prebuilt_deps_share_port_instances_with_repos() {
use std::sync::Arc;
let repos = crate::extractors::tests::test_repositories();
let user = Arc::clone(&repos.user);
let follow_graph = Arc::clone(&repos.follow_graph);
let state = crate::extractors::tests::build_test_state_from(
repos,
Arc::new(crate::extractors::tests::Panic),
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user),
"deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from"
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph),
"deps.social.query.follow_graph must be the same Arc as the Repositories it was built from"
);
assert_eq!(
state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance,
"prebuilt deps must carry the context's InstanceIdentity"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
use super::*;
fn make_form(watched_at: &str) -> LogReviewForm {
LogReviewForm {
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
watch_medium: None,
spoilers: false,
csrf_token: String::new(),
}
}
fn make_request(watched_at: &str) -> LogReviewRequest {
LogReviewRequest {
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
watch_medium: None,
spoilers: false,
}
}
#[test]
fn form_accepts_datetime_with_seconds() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30:00")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00");
}
#[test]
fn form_accepts_datetime_without_seconds() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30")).unwrap();
assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30");
}
#[test]
fn form_rejects_invalid_datetime() {
assert!(LogReviewData::try_from(make_form("not-a-date")).is_err());
}
#[test]
fn api_accepts_datetime_with_seconds() {
let data = LogReviewData::try_from(make_request("2024-03-15T20:30:00")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00");
}
#[test]
fn api_accepts_datetime_without_seconds() {
let data = LogReviewData::try_from(make_request("2024-03-15T20:30")).unwrap();
assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30");
}
#[test]
fn api_rejects_invalid_datetime() {
assert!(LogReviewData::try_from(make_request("garbage")).is_err());
}
#[test]
fn whitespace_external_id_becomes_none_in_form() {
let mut form = make_form("2024-03-15T20:30:00");
form.external_metadata_id = Some(" ".to_string());
let data = LogReviewData::try_from(form).unwrap();
assert!(data.external_metadata_id.is_none());
}
#[test]
fn whitespace_external_id_becomes_none_in_request() {
let mut req = make_request("2024-03-15T20:30:00");
req.external_metadata_id = Some(" ".to_string());
let data = LogReviewData::try_from(req).unwrap();
assert!(data.external_metadata_id.is_none());
}
#[test]
fn sort_by_asc_string_becomes_ascending() {
let params = DiaryQueryParams {
sort_by: Some("asc".to_string()),
limit: None,
offset: None,
movie_id: None,
user_id: None,
};
let query = to_diary_query(params);
assert!(matches!(
query.sort_by,
Some(domain::models::ReviewSortBy::Ascending)
));
}
#[test]
fn sort_by_other_string_becomes_descending() {
let params = DiaryQueryParams {
sort_by: Some("desc".to_string()),
limit: None,
offset: None,
movie_id: None,
user_id: None,
};
let query = to_diary_query(params);
assert!(matches!(
query.sort_by,
Some(domain::models::ReviewSortBy::Descending)
));
}
#[test]
fn form_accepts_date_only() {
let data = LogReviewData::try_from(make_form("2024-03-15")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "00:00:00");
assert_eq!(data.watched_at.format("%Y-%m-%d").to_string(), "2024-03-15");
}

View File

@@ -0,0 +1,2 @@
mod api_handlers;
mod context;