Skip to main content

max / makenotwork

5.1 KB · 154 lines History Blame Raw
1 //! Bundle management handlers for bundle-type items.
2
3 use axum::{
4 Json,
5 extract::{Path, State},
6 http::StatusCode,
7 response::IntoResponse,
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, ItemId, ItemType},
14 error::{AppError, Result},
15 };
16 use sqlx::PgPool;
17
18 use super::super::verify_item_ownership;
19
20 #[derive(Debug, Deserialize)]
21 pub(crate) struct BundleAddRequest {
22 pub item_id: ItemId,
23 }
24
25 #[derive(Debug, Deserialize)]
26 pub(crate) struct BundleListedRequest {
27 pub listed: bool,
28 }
29
30 /// POST /api/items/{id}/bundle/add: add an item to this bundle.
31 #[tracing::instrument(skip_all, name = "items::bundle_add")]
32 pub(crate) async fn bundle_add(
33 State(db): State<PgPool>,
34 AuthUser(user): AuthUser,
35 Path(bundle_id): Path<ItemId>,
36 Json(req): Json<BundleAddRequest>,
37 ) -> Result<impl IntoResponse> {
38 user.check_not_suspended()?;
39 let (item, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
40 if item.item_type != ItemType::Bundle {
41 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
42 }
43 let target = db::items::get_item_by_id(&db, req.item_id)
44 .await?
45 .ok_or(AppError::NotFound)?;
46 if target.project_id != item.project_id {
47 return Err(AppError::BadRequest(
48 "Item must be in the same project".to_string(),
49 ));
50 }
51 if target.item_type == ItemType::Bundle {
52 return Err(AppError::BadRequest("Cannot nest bundles".to_string()));
53 }
54 let count = db::bundles::get_bundle_item_count(&db, bundle_id).await?;
55 db::bundles::add_item_to_bundle(&db, bundle_id, req.item_id, count as i32).await?;
56 Ok(StatusCode::OK)
57 }
58
59 /// DELETE /api/items/{id}/bundle/{child_id}: remove an item from this bundle.
60 #[tracing::instrument(skip_all, name = "items::bundle_remove")]
61 pub(crate) async fn bundle_remove(
62 State(db): State<PgPool>,
63 AuthUser(user): AuthUser,
64 Path((bundle_id, child_id)): Path<(ItemId, ItemId)>,
65 ) -> Result<impl IntoResponse> {
66 user.check_not_suspended()?;
67 let (item, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
68 if item.item_type != ItemType::Bundle {
69 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
70 }
71 db::bundles::remove_item_from_bundle(&db, bundle_id, child_id).await?;
72 Ok(StatusCode::OK)
73 }
74
75 /// PUT /api/items/{id}/bundle/{child_id}/listed: toggle listed status.
76 #[tracing::instrument(skip_all, name = "items::bundle_toggle_listed")]
77 pub(crate) async fn bundle_toggle_listed(
78 State(db): State<PgPool>,
79 AuthUser(user): AuthUser,
80 Path((bundle_id, child_id)): Path<(ItemId, ItemId)>,
81 Json(req): Json<BundleListedRequest>,
82 ) -> Result<impl IntoResponse> {
83 user.check_not_suspended()?;
84 let (item, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
85 if item.item_type != ItemType::Bundle {
86 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
87 }
88 // Verify child actually belongs to this bundle before toggling
89 if !db::bundles::is_bundle_member(&db, bundle_id, child_id).await? {
90 return Err(AppError::NotFound);
91 }
92 db::bundles::set_item_listed(&db, child_id, req.listed, user.id).await?;
93 Ok(StatusCode::OK)
94 }
95
96 #[derive(Debug, Deserialize)]
97 pub(crate) struct BundleCreateChildRequest {
98 pub title: String,
99 pub description: Option<String>,
100 }
101
102 #[derive(Debug, Serialize)]
103 pub(super) struct BundleCreateChildResponse {
104 pub item_id: ItemId,
105 pub title: String,
106 }
107
108 /// POST /api/items/{id}/bundle/create-child: create a new item, add to bundle, set unlisted.
109 #[tracing::instrument(skip_all, name = "items::bundle_create_child")]
110 pub(crate) async fn bundle_create_child(
111 State(db): State<PgPool>,
112 AuthUser(user): AuthUser,
113 Path(bundle_id): Path<ItemId>,
114 Json(req): Json<BundleCreateChildRequest>,
115 ) -> Result<impl IntoResponse> {
116 user.check_not_suspended()?;
117 let (bundle, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
118 if bundle.item_type != ItemType::Bundle {
119 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
120 }
121
122 crate::validation::validate_item_title(&req.title)?;
123 if let Some(ref desc) = req.description {
124 crate::validation::validate_item_description(desc)?;
125 }
126
127 let child = db::items::create_item(
128 &db,
129 bundle.project_id,
130 &req.title,
131 req.description.as_deref(),
132 crate::db::PriceCents::from_db(0),
133 ItemType::Digital,
134 crate::db::AiTier::Handmade,
135 None,
136 )
137 .await?;
138
139 // Add to bundle and set unlisted
140 let count = db::bundles::get_bundle_item_count(&db, bundle_id).await?;
141 db::bundles::add_item_to_bundle(&db, bundle_id, child.id, count as i32).await?;
142 db::bundles::set_item_listed(&db, child.id, false, user.id).await?;
143
144 // Publish the child so it's downloadable via the bundle
145 db::items::bulk_publish(&db, &[child.id], bundle.project_id, user.id).await?;
146
147 db::projects::bump_cache_generation(&db, bundle.project_id).await?;
148
149 Ok(Json(BundleCreateChildResponse {
150 item_id: child.id,
151 title: child.title,
152 }))
153 }
154