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::{ csrf::CsrfToken, extractors::RequiredCookieUser, forms::ErrorQuery, render::render_page, state::AppState, }; use template_askama::{IntegrationsTemplate, WatchQueueTemplate}; use super::helpers::build_page_context; fn encode_error(msg: &str) -> String { use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() } // ── HTML ───────────────────────────────────────────────────────────────────── pub async fn get_integrations_page( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, Query(params): Query, Extension(csrf): Extension, ) -> 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.repos.webhook_token.clone(), query) .await .unwrap_or_default(); let token_views: Vec = 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, Extension(csrf): Extension, Form(form): Form, ) -> impl IntoResponse { if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } let provider = match form.provider.parse::() { 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.repos.webhook_token.clone(), 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, Path(token_id): Path, Extension(csrf): Extension, Form(form): Form, ) -> 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.repos.webhook_token.clone(), 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, Query(params): Query, Extension(csrf): Extension, ) -> 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.repos.watch_event.clone(), query) .await .unwrap_or_default(); let entries: Vec = 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, Path(event_id): Path, Extension(csrf): Extension, Form(form): Form, ) -> 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.repos.watch_event.clone(), state.app_ctx.services.review_logger.clone(), cmd, ) .await { Ok(_) => Redirect::to("/watch-queue").into_response(), Err(e) => { let msg = encode_error(&e.to_string()); Redirect::to(&format!("/watch-queue?error={msg}")).into_response() } } } pub async fn post_dismiss_single( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, Path(event_id): Path, Extension(csrf): Extension, Form(form): Form, ) -> 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.repos.watch_event.clone(), cmd).await { Ok(_) => Redirect::to("/watch-queue").into_response(), Err(e) => { let msg = encode_error(&e.to_string()); Redirect::to(&format!("/watch-queue?error={msg}")).into_response() } } }