Skip to main content

max / makenotwork

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