//! Library management: claim and remove free items. use axum::{ extract::{Path, State}, http::header::HeaderMap, response::{Html, IntoResponse, Response}, Json, }; use serde::Serialize; use crate::{ auth::AuthUser, db::{self, ItemId}, error::{AppError, Result}, helpers::{self, htmx_toast_response, is_htmx_request, spawn_email}, templates::{LibraryStatusTemplate, SaveStatusTemplate}, AppState, }; use super::SuccessMessageResponse; /// JSON response for library add/claim. #[derive(Debug, Serialize)] struct LibraryActionResponse { success: bool, claimed: bool, message: &'static str, } /// Claim a free item and add it to the user's library. #[tracing::instrument(skip_all, name = "users::add_to_library")] pub(in crate::routes::api) async fn add_to_library( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { user.check_not_sandbox()?; let is_htmx = is_htmx_request(&headers); // Get the item let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; // Draft items cannot be claimed if !item.is_public { return Err(AppError::NotFound); } // Verify item is free if item.price_cents != 0 { if is_htmx { return Ok(Html(SaveStatusTemplate { success: false, message: "This item is not free".to_string(), }.render_string()).into_response()); } return Err(AppError::BadRequest("This item is not free".to_string())); } // Get the project to find the seller let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; // Get the seller's username for transaction record let seller = db::users::get_user_by_id(&state.db, project.user_id) .await? .ok_or(AppError::NotFound)?; // Claim the free item + increment sales count atomically let mut tx = state.db.begin().await?; let claimed = db::transactions::claim_free_item( &mut *tx, &db::transactions::ClaimParams { buyer_id: user.id, item_id, seller_id: project.user_id, item_title: &item.title, seller_username: &seller.username, share_contact: false, parent_transaction_id: None, }, ).await?; if claimed { db::items::increment_sales_count(&mut *tx, item_id).await?; } tx.commit().await?; // Grant access to bundle child items if claimed && item.item_type == db::ItemType::Bundle { crate::routes::stripe::grant_bundle_items( &state, item_id, user.id, project.user_id, None, ) .await; } // Generate license key if item has keys enabled and was newly claimed if claimed && item.enable_license_keys { let key_code = helpers::generate_key_code(); match db::license_keys::create_license_key( &state.db, item_id, user.id, None, // free claim — no transaction linked &key_code, item.default_max_activations, ) .await { Ok(_) => { tracing::info!(buyer_id = %user.id, item_id = %item_id, "license key generated for free claim"); } Err(e) => { tracing::error!(error = ?e, "failed to generate license key for free claim"); } } } // Notify seller of free claim (fire-and-forget) if claimed && seller.notify_sale { let buyer_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten(); let buyer_username = buyer_user.as_ref() .map(|b| b.username.to_string()) .unwrap_or_else(|| "Someone".to_string()); let item_title = item.title.clone(); let seller_email = seller.email.clone(); let seller_name = seller.display_name.clone(); let unsub_url = crate::email::generate_unsubscribe_url( &state.config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &state.config.signing_secret, ); spawn_email!(state, "sale notification", |email| { email.send_sale_notification( &seller_email, seller_name.as_deref(), &buyer_username, &item_title, "Free", Some(&unsub_url), ) }); } if is_htmx { let message = if claimed { "Added to library" } else { "Already in library" }; return Ok(LibraryStatusTemplate { message: message.to_string() }.into_response()); } Ok(Json(LibraryActionResponse { success: true, claimed, message: if claimed { "Added to library" } else { "Already in library" }, }).into_response()) } /// Remove a free item from the user's library. #[tracing::instrument(skip_all, name = "users::remove_from_library")] pub(in crate::routes::api) async fn remove_from_library( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { let is_htmx = is_htmx_request(&headers); // Remove the free item from library let removed = db::transactions::remove_free_item_from_library(&state.db, user.id, item_id).await?; // Decrement denormalized sales_count if removed { db::items::decrement_sales_count(&state.db, item_id).await?; } if is_htmx { if removed { return Ok(htmx_toast_response("Removed from library", "success").into_response()); } else { return Ok(Html(SaveStatusTemplate { success: false, message: "Could not remove item".to_string(), }.render_string()).into_response()); } } Ok(Json(SuccessMessageResponse { success: removed, message: if removed { "Removed from library" } else { "Could not remove item" }, }).into_response()) }