//! Library management: claim and remove free items. use axum::{ Json, extract::{Path, State}, http::header::HeaderMap, response::{Html, IntoResponse, Response}, }; use serde::Serialize; use sqlx::PgPool; use crate::{ auth::AuthUser, background::BackgroundTx, config::Config, db::{self, ItemId}, email::EmailClient, error::{AppError, Result}, helpers::{self, htmx_toast_response, is_htmx_request}, templates::{LibraryStatusTemplate, SaveStatusTemplate}, }; 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(db): State, State(config): State, State(email): State, State(bg): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { user.check_not_sandbox()?; let is_htmx = is_htmx_request(&headers); let item = db::items::get_item_by_id(&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(&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(&db, project.user_id) .await? .ok_or(AppError::NotFound)?; // Claim the free item + increment sales count atomically let mut tx = 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, platform_credit_cents: 0, // adding a genuinely-free item to the library }, ) .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(&db, 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( &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). The Sale preference is // checked by the send path, not here. if claimed { let buyer_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten(); let buyer_username = buyer_user .as_ref() .map_or_else(|| "Someone".to_string(), |b| b.username.to_string()); let item_title = item.title.clone(); let seller_user_id = seller.id; let seller_email = seller.email.clone(); let seller_name = seller.display_name.clone(); let unsub_url = crate::email::generate_unsubscribe_url( &config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &config.signing_secret, ); let email = email.clone(); bg.spawn("sale notification", async move { if let Err(e) = email .send_sale_notification( seller_user_id, &seller_email, seller_name.as_deref(), &buyer_username, &item_title, "Free", Some(&unsub_url), ) .await { tracing::error!(error = ?e, "failed to send sale notification"); } }); } 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(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { let is_htmx = is_htmx_request(&headers); let removed = db::transactions::remove_free_item_from_library(&db, user.id, item_id).await?; // Decrement denormalized sales_count if removed { db::items::decrement_sales_count(&db, item_id).await?; } if is_htmx { if removed { return Ok(htmx_toast_response("Removed from library", "success").into_response()); } 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()) }