Skip to main content

max / makenotwork

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