Skip to main content

max / makenotwork

3.9 KB · 133 lines History Blame Raw
1 //! Cart API: add/remove items, get count.
2
3 use axum::extract::{Path, State};
4 use axum::response::IntoResponse;
5 use axum::Json;
6
7 use crate::{
8 auth::AuthUser,
9 db::{self, ItemId},
10 error::{AppError, Result},
11 AppState,
12 };
13
14 /// Toggle an item's cart status. Returns the new state.
15 #[tracing::instrument(skip_all, name = "cart::toggle")]
16 pub(super) async fn toggle_cart(
17 State(state): State<AppState>,
18 AuthUser(user): AuthUser,
19 Path(item_id): Path<ItemId>,
20 ) -> Result<impl IntoResponse> {
21 // Single-query pre-flight: item existence, visibility, ownership, purchase, cart status
22 let pf = db::cart::toggle_cart_preflight(&state.db, user.id, item_id)
23 .await?
24 .ok_or(AppError::NotFound)?;
25
26 if !pf.is_public {
27 return Err(AppError::NotFound);
28 }
29 if !pf.listed {
30 // Unlisted items are bundle-only — `item.rs:47-49` enforces this on the
31 // single-item checkout path; the cart flow must enforce the same gate
32 // or the bundle-only restriction is bypassable by any UUID guesser.
33 return Err(AppError::BadRequest(
34 "This item is only available through its bundle.".to_string(),
35 ));
36 }
37 if pf.is_owner {
38 return Err(AppError::BadRequest(
39 "You can't add your own items to your cart.".to_string(),
40 ));
41 }
42 if pf.has_purchased {
43 return Err(AppError::BadRequest(
44 "You already own this item.".to_string(),
45 ));
46 }
47
48 if pf.in_cart {
49 db::cart::remove_from_cart(&state.db, user.id, item_id).await?;
50 } else {
51 db::cart::add_to_cart(&state.db, user.id, item_id).await?;
52 }
53
54 Ok(Json(serde_json::json!({ "in_cart": !pf.in_cart })))
55 }
56
57 /// Remove an item from the cart explicitly. Returns 204.
58 #[tracing::instrument(skip_all, name = "cart::remove")]
59 pub(super) async fn remove_from_cart(
60 State(state): State<AppState>,
61 AuthUser(user): AuthUser,
62 Path(item_id): Path<ItemId>,
63 ) -> Result<impl IntoResponse> {
64 db::cart::remove_from_cart(&state.db, user.id, item_id).await?;
65 Ok(axum::http::StatusCode::NO_CONTENT)
66 }
67
68 /// Update the PWYW amount for a cart item.
69 #[tracing::instrument(skip_all, name = "cart::update_amount")]
70 pub(super) async fn update_cart_amount(
71 State(state): State<AppState>,
72 AuthUser(user): AuthUser,
73 Path(item_id): Path<ItemId>,
74 Json(body): Json<UpdateCartAmountRequest>,
75 ) -> Result<impl IntoResponse> {
76 // Verify item exists and is PWYW
77 let item = db::items::get_item_by_id(&state.db, item_id)
78 .await?
79 .ok_or(AppError::NotFound)?;
80
81 if !item.pwyw_enabled {
82 return Err(AppError::BadRequest(
83 "This item does not use pay-what-you-want pricing.".to_string(),
84 ));
85 }
86
87 // Validate amount against minimum
88 let min = item.pwyw_min_cents.unwrap_or(0);
89 if body.amount_cents < min {
90 return Err(AppError::BadRequest(format!(
91 "Amount must be at least ${}.{:02}.",
92 min / 100,
93 min % 100
94 )));
95 }
96
97 // Cap at $10,000
98 if body.amount_cents > 1_000_000 {
99 return Err(AppError::BadRequest(
100 "Amount cannot exceed $10,000.".to_string(),
101 ));
102 }
103
104 let updated = db::cart::update_cart_amount(
105 &state.db,
106 user.id,
107 item_id,
108 Some(body.amount_cents),
109 )
110 .await?;
111
112 if !updated {
113 return Err(AppError::NotFound);
114 }
115
116 Ok(Json(serde_json::json!({ "amount_cents": body.amount_cents })))
117 }
118
119 #[derive(Debug, serde::Deserialize)]
120 pub(super) struct UpdateCartAmountRequest {
121 pub amount_cents: i32,
122 }
123
124 /// Get the number of items in the cart (for nav badge).
125 #[tracing::instrument(skip_all, name = "cart::count")]
126 pub(super) async fn cart_count(
127 State(state): State<AppState>,
128 AuthUser(user): AuthUser,
129 ) -> Result<impl IntoResponse> {
130 let count = db::cart::get_cart_count(&state.db, user.id).await?;
131 Ok(Json(serde_json::json!({ "count": count })))
132 }
133