//! Custom link API: create, update, delete, reorder. use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{Html, IntoResponse, Response}, Form, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, CustomLinkId}, error::{AppError, Result}, helpers::{htmx_toast_response, is_htmx_request}, templates::LinkRowTemplate, validation, AppState, }; // ============================================================================= // Custom Links API // ============================================================================= /// Form input for creating a custom profile link. #[derive(Debug, Deserialize)] pub struct CreateLinkRequest { pub url: String, pub title: String, pub description: Option, } /// JSON response representing a custom profile link. #[derive(Debug, Serialize)] pub struct LinkResponse { pub id: CustomLinkId, pub url: String, pub title: String, pub description: Option, pub sort_order: i32, } /// Create a new custom profile link for the authenticated user. #[tracing::instrument(skip_all, name = "links::create_link")] pub(super) async fn create_link( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; // Validate input validation::validate_link_url(&req.url)?; validation::validate_link_title(&req.title)?; let link = db::custom_links::create_custom_link( &state.db, user.id, &req.url, &req.title, req.description.as_deref(), ) .await?; if is_htmx_request(&headers) { return Ok(Html(LinkRowTemplate { id: link.id.to_string(), title: link.title, url: link.url, }.render_string()).into_response()); } Ok(Json(LinkResponse { id: link.id, url: link.url, title: link.title, description: link.description, sort_order: link.sort_order, }).into_response()) } /// JSON input for updating a custom profile link. #[derive(Debug, Deserialize)] pub struct UpdateLinkRequest { pub url: Option, pub title: Option, pub description: Option, } /// Update an existing custom profile link owned by the user. #[tracing::instrument(skip_all, name = "links::update_link")] pub(super) async fn update_link( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; // Validate input (same rules as create_link, but all fields are optional) if let Some(ref url) = req.url { validation::validate_link_url(url)?; } if let Some(ref title) = req.title { validation::validate_link_title(title)?; } // Verify ownership with efficient single-row check if !db::custom_links::user_owns_custom_link(&state.db, user.id, id).await? { return Err(AppError::NotFound); } let link = db::custom_links::update_custom_link( &state.db, id, user.id, req.url.as_deref(), req.title.as_deref(), req.description.as_deref(), ) .await?; Ok(Json(LinkResponse { id: link.id, url: link.url, title: link.title, description: link.description, sort_order: link.sort_order, })) } /// Delete a custom profile link owned by the user. #[tracing::instrument(skip_all, name = "links::delete_link")] pub(super) async fn delete_link( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; // Verify ownership with efficient single-row check if !db::custom_links::user_owns_custom_link(&state.db, user.id, id).await? { return Err(AppError::NotFound); } db::custom_links::delete_custom_link(&state.db, id, user.id).await?; if is_htmx_request(&headers) { return Ok(htmx_toast_response("Link removed", "success").into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// JSON input for reordering custom profile links. #[derive(Debug, Deserialize)] pub struct ReorderLinksRequest { pub link_ids: Vec, } /// Reorder the authenticated user's custom profile links. #[tracing::instrument(skip_all, name = "links::reorder_links")] pub(super) async fn reorder_links( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; db::custom_links::reorder_custom_links(&state.db, user.id, &req.link_ids).await?; Ok(StatusCode::NO_CONTENT) }