Skip to main content

max / makenotwork

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