Skip to main content

max / makenotwork

6.2 KB · 192 lines History Blame Raw
1 //! Library management: claim and remove free items.
2
3 use axum::{
4 extract::{Path, State},
5 http::header::HeaderMap,
6 response::{Html, IntoResponse, Response},
7 Json,
8 };
9 use serde::Serialize;
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, ItemId},
14 error::{AppError, Result},
15 helpers::{self, htmx_toast_response, is_htmx_request, spawn_email},
16 templates::{LibraryStatusTemplate, SaveStatusTemplate},
17 AppState,
18 };
19
20 use super::SuccessMessageResponse;
21
22 /// JSON response for library add/claim.
23 #[derive(Debug, Serialize)]
24 struct LibraryActionResponse {
25 success: bool,
26 claimed: bool,
27 message: &'static str,
28 }
29
30 /// Claim a free item and add it to the user's library.
31 #[tracing::instrument(skip_all, name = "users::add_to_library")]
32 pub(in crate::routes::api) async fn add_to_library(
33 State(state): State<AppState>,
34 headers: HeaderMap,
35 AuthUser(user): AuthUser,
36 Path(item_id): Path<ItemId>,
37 ) -> Result<Response> {
38 user.check_not_sandbox()?;
39 let is_htmx = is_htmx_request(&headers);
40
41 // Get the item
42 let item = db::items::get_item_by_id(&state.db, item_id)
43 .await?
44 .ok_or(AppError::NotFound)?;
45
46 // Draft items cannot be claimed
47 if !item.is_public {
48 return Err(AppError::NotFound);
49 }
50
51 // Verify item is free
52 if item.price_cents != 0 {
53 if is_htmx {
54 return Ok(Html(SaveStatusTemplate {
55 success: false,
56 message: "This item is not free".to_string(),
57 }.render_string()).into_response());
58 }
59 return Err(AppError::BadRequest("This item is not free".to_string()));
60 }
61
62 // Get the project to find the seller
63 let project = db::projects::get_project_by_id(&state.db, item.project_id)
64 .await?
65 .ok_or(AppError::NotFound)?;
66
67 // Get the seller's username for transaction record
68 let seller = db::users::get_user_by_id(&state.db, project.user_id)
69 .await?
70 .ok_or(AppError::NotFound)?;
71
72 // Claim the free item + increment sales count atomically
73 let mut tx = state.db.begin().await?;
74 let claimed = db::transactions::claim_free_item(
75 &mut *tx,
76 &db::transactions::ClaimParams {
77 buyer_id: user.id,
78 item_id,
79 seller_id: project.user_id,
80 item_title: &item.title,
81 seller_username: &seller.username,
82 share_contact: false,
83 parent_transaction_id: None,
84 },
85 ).await?;
86
87 if claimed {
88 db::items::increment_sales_count(&mut *tx, item_id).await?;
89 }
90 tx.commit().await?;
91
92 // Grant access to bundle child items
93 if claimed && item.item_type == db::ItemType::Bundle {
94 crate::routes::stripe::grant_bundle_items(
95 &state, item_id, user.id, project.user_id, None,
96 )
97 .await;
98 }
99
100 // Generate license key if item has keys enabled and was newly claimed
101 if claimed && item.enable_license_keys {
102 let key_code = helpers::generate_key_code();
103 match db::license_keys::create_license_key(
104 &state.db,
105 item_id,
106 user.id,
107 None, // free claim — no transaction linked
108 &key_code,
109 item.default_max_activations,
110 )
111 .await
112 {
113 Ok(_) => {
114 tracing::info!(buyer_id = %user.id, item_id = %item_id, "license key generated for free claim");
115 }
116 Err(e) => {
117 tracing::error!(error = ?e, "failed to generate license key for free claim");
118 }
119 }
120 }
121
122 // Notify seller of free claim (fire-and-forget)
123 if claimed && seller.notify_sale {
124 let buyer_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten();
125 let buyer_username = buyer_user.as_ref()
126 .map(|b| b.username.to_string())
127 .unwrap_or_else(|| "Someone".to_string());
128 let item_title = item.title.clone();
129 let seller_email = seller.email.clone();
130 let seller_name = seller.display_name.clone();
131 let unsub_url = crate::email::generate_unsubscribe_url(
132 &state.config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &state.config.signing_secret,
133 );
134 spawn_email!(state, "sale notification", |email| {
135 email.send_sale_notification(
136 &seller_email,
137 seller_name.as_deref(),
138 &buyer_username,
139 &item_title,
140 "Free",
141 Some(&unsub_url),
142 )
143 });
144 }
145
146 if is_htmx {
147 let message = if claimed { "Added to library" } else { "Already in library" };
148 return Ok(LibraryStatusTemplate { message: message.to_string() }.into_response());
149 }
150
151 Ok(Json(LibraryActionResponse {
152 success: true,
153 claimed,
154 message: if claimed { "Added to library" } else { "Already in library" },
155 }).into_response())
156 }
157
158 /// Remove a free item from the user's library.
159 #[tracing::instrument(skip_all, name = "users::remove_from_library")]
160 pub(in crate::routes::api) async fn remove_from_library(
161 State(state): State<AppState>,
162 headers: HeaderMap,
163 AuthUser(user): AuthUser,
164 Path(item_id): Path<ItemId>,
165 ) -> Result<Response> {
166 let is_htmx = is_htmx_request(&headers);
167
168 // Remove the free item from library
169 let removed = db::transactions::remove_free_item_from_library(&state.db, user.id, item_id).await?;
170
171 // Decrement denormalized sales_count
172 if removed {
173 db::items::decrement_sales_count(&state.db, item_id).await?;
174 }
175
176 if is_htmx {
177 if removed {
178 return Ok(htmx_toast_response("Removed from library", "success").into_response());
179 } else {
180 return Ok(Html(SaveStatusTemplate {
181 success: false,
182 message: "Could not remove item".to_string(),
183 }.render_string()).into_response());
184 }
185 }
186
187 Ok(Json(SuccessMessageResponse {
188 success: removed,
189 message: if removed { "Removed from library" } else { "Could not remove item" },
190 }).into_response())
191 }
192