Skip to main content

max / makenotwork

6.7 KB · 231 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)
133 if claimed && seller.notify_sale {
134 let buyer_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
135 let buyer_username = buyer_user
136 .as_ref()
137 .map_or_else(|| "Someone".to_string(), |b| b.username.to_string());
138 let item_title = item.title.clone();
139 let seller_email = seller.email.clone();
140 let seller_name = seller.display_name.clone();
141 let unsub_url = crate::email::generate_unsubscribe_url(
142 &config.host_url,
143 seller.id,
144 crate::email::UnsubscribeAction::Sale,
145 &seller.id.to_string(),
146 &config.signing_secret,
147 );
148 let email = email.clone();
149 bg.spawn("sale notification", async move {
150 if let Err(e) = email
151 .send_sale_notification(
152 &seller_email,
153 seller_name.as_deref(),
154 &buyer_username,
155 &item_title,
156 "Free",
157 Some(&unsub_url),
158 )
159 .await
160 {
161 tracing::error!(error = ?e, "failed to send sale notification");
162 }
163 });
164 }
165
166 if is_htmx {
167 let message = if claimed {
168 "Added to library"
169 } else {
170 "Already in library"
171 };
172 return Ok(LibraryStatusTemplate {
173 message: message.to_string(),
174 }
175 .into_response());
176 }
177
178 Ok(Json(LibraryActionResponse {
179 success: true,
180 claimed,
181 message: if claimed {
182 "Added to library"
183 } else {
184 "Already in library"
185 },
186 })
187 .into_response())
188 }
189
190 /// Remove a free item from the user's library.
191 #[tracing::instrument(skip_all, name = "users::remove_from_library")]
192 pub(in crate::routes::api) async fn remove_from_library(
193 State(db): State<PgPool>,
194 headers: HeaderMap,
195 AuthUser(user): AuthUser,
196 Path(item_id): Path<ItemId>,
197 ) -> Result<Response> {
198 let is_htmx = is_htmx_request(&headers);
199
200 let removed = db::transactions::remove_free_item_from_library(&db, user.id, item_id).await?;
201
202 // Decrement denormalized sales_count
203 if removed {
204 db::items::decrement_sales_count(&db, item_id).await?;
205 }
206
207 if is_htmx {
208 if removed {
209 return Ok(htmx_toast_response("Removed from library", "success").into_response());
210 }
211 return Ok(Html(
212 SaveStatusTemplate {
213 success: false,
214 message: "Could not remove item".to_string(),
215 }
216 .render_string()?,
217 )
218 .into_response());
219 }
220
221 Ok(Json(SuccessMessageResponse {
222 success: removed,
223 message: if removed {
224 "Removed from library"
225 } else {
226 "Could not remove item"
227 },
228 })
229 .into_response())
230 }
231